Slow on purpose, fast in tests: a 10x CI win without touching security

A password hash is supposed to be slow — that’s part of the defense. But our test suite was paying that cost thousands of times per run. Here’s how we cut one package’s test time from 391 seconds to 40 without weakening a single production setting.

Frank Heikens··4 min read

One of Workbench's Go test packages was taking 391 seconds under the race detector.

The obvious culprit was password hashing.

The problem: password hashing is supposed to be slow.

We store credentials using argon2id, a memory-hard hashing function configured to consume real CPU and 64 MiB of memory per hash. That cost is not overhead to optimize away. It's the defense that makes a stolen password database expensive to attack instead of trivial.

Fast password hashing would be a security bug.

So simply making the hashing faster wasn't an acceptable solution.

Measure first

Before changing anything, we measured.

At our production parameters, a single argon2id hash takes about 102 milliseconds when running under Go's -race detector. The race detector adds instrumentation around memory accesses, making a memory-intensive workload such as argon2id considerably more expensive.

For a login, spending roughly 100 milliseconds is perfectly reasonable.

But test suites multiply costs.

The application hashes credentials during boot for a bootstrap admin credential and a dummy hash used for constant-time verification. It also hashes during login and password changes.

Our internal/app package exercises that machinery across roughly forty test files.

A cost that's insignificant once becomes substantial when repeated thousands of times.

At 102 milliseconds each, even 3,000 hashes would represent more than five minutes of hashing work.

That explained most of our 391-second test run.

The hash wasn't too expensive.

We were paying the production security cost in places that didn't need production security parameters.

Change the test, not the product

Consider a test that verifies that the wrong password is rejected.

What property are we actually testing?

We're testing authentication behavior:

  • A correct password succeeds.
  • An incorrect password fails.
  • A weak credential is rehashed when appropriate.
  • Password changes produce the expected authentication state.

None of those behaviors requires every hash operation to consume 64 MiB and run at full production cost.

So we changed the test environment instead.

The internal/app test suite now lowers the argon2 parameters it uses before running.

Production remains unchanged.

Three details make that distinction important.

Production keeps the full security floor

The shipped application still uses the full 64 MiB parameter floor.

No production configuration was reduced. No authentication path was weakened. No security tradeoff was made to improve CI performance.

The production security floor has its own test

The tests that verify our actual argon2 security parameters run in a separate package.

They see the real production floor.

If someone later reduces those parameters, those tests fail.

Making the application tests cheaper therefore cannot silently redefine what Elevarq considers an acceptable password-hashing configuration.

Authentication behavior remains identical

The cheaper test parameters still satisfy the test-scoped floor.

That matters because Workbench also handles credentials that need to be upgraded. If a stored credential no longer meets the required parameters, a successful login can trigger a rehash.

That path still executes in the application tests.

We're not bypassing the security logic.

We're executing the same logic with cheaper inputs.

391 seconds became 40

After the change, the race-enabled test time for the package dropped from:

391 seconds → 40 seconds

Almost a 10x improvement.

Every test still passes.

And because this package dominated the suite, the change roughly halves the total go test -race gate that runs on every change.

That's not just a CI optimization.

Feedback-loop latency affects engineering behavior.

A test that takes 40 seconds is easy to run while developing. A test that takes six and a half minutes is much easier to postpone and leave to CI.

Slow tests create subtle pressure to test less often.

That's exactly the opposite of what you want from security-sensitive code.

The pattern: invariant isolation

There's a broader engineering pattern here.

Call it invariant isolation.

Production has one invariant:

Password hashing must remain expensive enough to resist offline attacks.

Most application tests have another:

Authentication behavior must remain correct.

Only the tests protecting the first invariant need to pay the full computational cost of proving it.

The rest can test their own invariant with cheaper parameters.

The same principle applies beyond password hashing.

Cryptography, retry intervals, timeouts, large datasets, storage durability checks, rate limits and external integrations can all contain deliberate production costs.

The question shouldn't be:

"How do we make this expensive thing cheaper?"

It should be:

"Which tests actually need to prove that it is expensive?"

Fast feedback doesn't require weaker defaults

"Is this secure?" and "is this fast to test?" are different questions.

Conflating them tends to produce bad answers in both directions.

You either weaken a production setting because tests became unbearable, or accept an unnecessarily slow test suite because the production setting feels untouchable.

Neither is necessary.

Hold the production invariant fixed.

Measure where the cost actually lands.

Separate the test that protects the expensive property from the tests that only need to exercise its behavior.

Then optimize everything else.

This is the kind of distinction we care about while building Elevarq: optimize aggressively where cost is incidental, but don't move the invariant that made the system safe in the first place.

391 seconds became 40.

The security setting didn't move.

Written by

Frank Heikens

Frank Heikens is the founder of Elevarq and a PostgreSQL engineer with 20+ years of experience in database performance and architecture. His work has focused on large-scale, performance-critical PostgreSQL systems where latency, throughput, and reliability matter. At Elevarq, he focuses on understanding why PostgreSQL systems behave the way they do—and turning that understanding into practical, evidence-based recommendations.

Reactions