← Blog

September 30, 2026 · Steve Johnson

Bcrypt Hash Migration With Lazy Rehashing in Production

Bcrypt Hash Migration With Lazy Rehashing in Production.

Stale bcrypt hashes and the difficulty of swapping them out

Bcrypt hash migration is a solved problem in principle: lazy rehashing lets a team upgrade every password hash in a production database without downtime and without forcing a single user to reset anything. But "solved in principle" hides a lot of engineering that has to be gotten right, from detection logic to schema design to the stubborn tail of accounts that never log in to trigger the upgrade.

The mechanism that makes hashes go stale is the starting point. Bcrypt's cost factor is an exponent, not a linear dial: a cost of 10 means 2^10, or 1,024 iterations of the EksBlowfish key schedule, run every single time someone logs in bcrypt Explained: How It Works and When to Use It in 2026. That number was chosen against the hardware available when someone typed it into a config file, and hardware does not sit still. A team running bcrypt at cost 10 since 2021 watched login response times climb to 2.3 seconds once daily active users scaled from 5,000 to 50,000, and the cause wasn't a bug anyone could patch Dev Genius. It was the arithmetic of a fixed cost factor meeting a growing user base Dev Genius.

The threat side moves in the same direction, just aimed the other way. The CPU improvements that slow down a login server hand attackers faster cracking rigs at the same time: a single high-end GPU can run bcrypt at cost 10 at roughly 500 hashes per second Password Hashing in 2026. OWASP's own risk ranking backs this up directly, with cryptographic failures at #4 on the 2025 Top 10, and outdated hash parameters run squarely inside that category.

Migration is hard mainly because a bulk overnight job can't simply upgrade every hash, and that isn't a policy question. Bcrypt hashes are one-way by design. There's no operation that takes a stored hash and produces a stronger one without the original plaintext password in hand, so a bulk overnight job that "upgrades everyone" is a physical impossibility, not an engineering shortcut someone forgot to build. Every plaintext password exists at exactly one moment after storage: the instant a user types it in to log in. That constraint is a physical limit built into the system. It's the reason lazy rehashing is the only clean answer to this problem, and everything downstream in this piece follows from that single fact.

Bcrypt's status in 2026 and the cost factor to target

The obvious follow-up question is what cost factor to migrate toward, and OWASP's Password Storage Cheat Sheet gives a floor, not a target: a minimum work factor of 10, with guidance to push it as high as server performance allows. "As high as performance allows" is doing a lot of work in that sentence, and the mainstream has already moved past the floor. PHP 8.4 raised its own default bcrypt cost from 10 to 12, which is as clear a signal as the ecosystem sends about where consensus has landed.

Those numbers should sit inside a specific window: bcrypt cost 10 hashing takes approximately 500 hashes per second, up from roughly 200. Dropping below 200ms opens the door wider for brute-force attempts, while climbing above 500ms turns the login endpoint itself into a denial-of-service vector Password Hashing in 2026. That is a server bill. It is a server bill.

A word of restraint belongs here too. OWASP now ranks Argon2id first for new systems, with scrypt as a fallback and bcrypt explicitly positioned for legacy systems, and it would be easy to read that ranking as an argument for abandoning bcrypt. That's a different project with a different risk profile, and this piece is about upgrading bcrypt cleanly, not talking anyone into a full algorithm swap they haven't planned for. Teams that do evaluate the jump should know OWASP's minimum parameters for Argon2id: 19 MiB of memory, 2 iterations, and a parallelism degree of 1.

One more detail belongs in this section because it changes what the new hash actually protects. Bcrypt silently truncates any password past 72 bytes, which matters more than it sounds like it should now that passphrases and machine-generated secrets run long bcrypt Explained: How It Works and When to Use It in 2026. A migration that ignores this either needs to handle the truncation deliberately or adopt OWASP's sanctioned pre-hashing construction, and pretending the limit doesn't exist just means shipping the same silent flaw forward into the new hash column bcrypt Explained: How It Works and When to Use It in 2026. On modern x86 hardware, a cost factor of 12 runs roughly 250–400ms and a cost factor of 14 runs around 1.5s, so teams should always benchmark on actual production hardware, not a laptop bcrypt Explained: How It Works and When to Use It in 2026. At a 300ms average hash time and 100 logins/sec peak, that equals 30 CPU-seconds per second, or 30 dedicated cores doing nothing but hashing. At sub-500ms auth speeds, properly tuned bcrypt can remain competitive with Argon2id, since Argon2 must be tuned down to meet latency budgets, sometimes reducing its practical advantage Password Hashing in 2026.

How lazy rehashing works: the login-time interception pattern

Diagram: The Lazy Rehash Flow: Login-Time Interception. Visualizes: Visualize the step-by-step decision flow of lazy rehashing during a login event.

The pattern itself is simple to state and only slightly more involved to build correctly. On every successful login, the application checks the stored hash's declared parameters against the current target. If the hash falls short, the plaintext password, sitting in memory at that exact moment because the user just typed it, gets rehashed under the new parameters and written back before the response goes out.

Walk through the sequence in order. The user submits credentials, and the server loads the stored hash from the database. Verification runs against that hash using its own declared parameters, since bcrypt strings are self-describing: version, cost, salt, and digest all live in one string. If verification fails, the flow stops there and returns an auth error, with no rehash logic touched. Below target, the plaintext gets hashed fresh and the record updates before login completes. At or above target, the login just completes, no write required.

That self-describing format is the detail that keeps this from needing a separate metadata table just to function. No coordination step, no separate lookup, no risk of the metadata drifting out of sync with the hash itself.

One rule sits above all the implementation detail: the plaintext password stays in memory for the shortest time the logic allows, and it never gets queued, logged, or written anywhere persistent at any point in this flow. That rule holds regardless of what the migration target looks like. The same interception pattern covers a cost-only upgrade from bcrypt 10 to bcrypt 12 just as cleanly as it covers a full algorithm crossing from bcrypt to Argon2id. What changes between those two cases is the detection and update logic inside the interception point bcrypt Explained: How It Works and When to Use It in 2026. And because the whole thing runs invisibly on a successful login, there's no password reset email, no forced prompt, no user-facing communication needed for the ordinary case. Users who log in normally simply get upgraded without knowing it happened. If verification succeeds, the cost factor is extracted from the hash prefix, specifically characters 4–6 of the bcrypt string. The self-describing format $2b$12$... encodes version, cost, and a 22-character salt inline, which is what allows old and new hashes to coexist in the same column, each declaring its own parameters, without a separate metadata column.

Versioning the hash column and structuring the detection logic

Two designs handle detection, and the choice between them depends almost entirely on how far the migration travels bcrypt Explained: How It Works and When to Use It in 2026. Option A leans on the self-describing bcrypt string alone: parse the prefix on every login, no schema change required, works immediately for a pure cost-factor upgrade. Option B adds an explicit hash_version or password_algorithm column, which costs a migration up front but pays back in readability and in the ability to query migration progress directly, something like "how many accounts are still on the old scheme" as a fast, indexed count.

For a bcrypt-only cost bump, prefix parsing is the lower-risk, lower-effort choice, and there's little reason to complicate the schema for it. Crossing an algorithm boundary is a different situation. Once bcrypt and Argon2id (or any second algorithm) share a column, the prefix alone may not carry enough information to dispatch cleanly to the right verifier, and an explicit version column becomes the safer design.

Whichever path a team takes, one habit is non-negotiable: never store a bare hash digest without its algorithm identifier, version, and parameters attached. That full encoded string is the entire basis for every piece of detection logic described here, and stripping it down to save a few bytes just breaks the migration before it starts.

Timing matters as much as detection. The rehash write needs to land before the session is established, synchronously, not handed off to a background job. A background job creates a race condition: the user could log in a second time before the job finishes, hit the still-unupgraded hash again, and trigger a redundant rehash cycle that gains nothing. Keeping the write in the request path closes that gap.

Teams on.NET 8 have a clean structural answer available for deciding which hasher implementation runs for which hash version. Microsoft's Keyed Services pattern lets multiple IPasswordHasher implementations register under distinct keys, string names or an enum, so each algorithm version becomes its own named service, and the login handler just selects the right one based on the stored hash's metadata. That's a tidy way to avoid a sprawling if/else chain growing every time a new algorithm version gets added.

Observability rounds this out. Log rehash events as counts, tracking how many succeed per time window, since that count is the actual migration progress metric. What should never appear in any logging or observability pipeline, under any circumstance, is a password, a hash, or a hash fragment.

The long tail: inactive users who never trigger a rehash

Lazy rehashing only upgrades the accounts that log in. Every account that doesn't becomes part of a permanent tail sitting on old parameters indefinitely, and that tail doesn't shrink on its own. The old verification path has to stay deployed for as long as a single old-format hash remains in the database. Pulling it early locks out the accounts still on it, defeating the point of avoiding forced resets.

Three approaches handle that tail, and they're not mutually exclusive. The simplest is leaving dormant accounts alone indefinitely: the old verification path stays live, and this is entirely acceptable as long as the old hashes are still bcrypt at a defensible cost rather than something like MD5 or SHA-1, the column is structured correctly, and the remaining count gets tracked over time. A second option sets a hard deadline: accounts inactive past a cutoff get their hash nulled, with a reset prompt waiting on next login attempt, announced with enough lead time that it doesn't blindside anyone. A third, hybrid approach targets the tail directly without forcing anything: accounts dormant beyond some threshold get a proactive, encouraged (not mandatory) reset prompt, while the lazy path stays in place to catch anyone who wanders back in on their own schedule.

Keycloak's approach to this problem is worth studying regardless of which identity stack a team runs. Its model keeps the legacy verifier deployed until old hashes are confirmed gone from the database, a condition tied to actual data state rather than a calendar date picked in advance.

For teams facing an unusually large dormant population, delegating the problem to an identity provider is a legitimate escape hatch rather than an admission of defeat. Keycloak, paired with a third-party bcrypt PasswordHashProvider extension, can verify against an imported bcrypt hash on first login and then automatically rehash under the realm's current algorithm, Argon2 by default in non-FIPS environments since Keycloak 25.0. That import-and-upgrade pattern moves the entire migration concern out of the application layer and into the identity provider, which is a genuinely different architecture, not just a convenience wrapper around the same logic. The Skycloak / Keycloak model keeps the legacy verifier deployed until old hashes are confirmed gone from the database, not until a calendar date.

Knowing when the migration is complete enough to drop the old path

"Complete enough" has to be defined before the migration starts, not negotiated after the fact once someone asks why the old code path is still in production. Waiting until the question comes up is how teams end up making that call under pressure, with incomplete data.

Cryptographic failures are #4 in the OWASP Top 10 (2025), and outdated hash parameters are squarely in this category. Track total hashes in the database segmented by algorithm and cost version, rehash events per day pulled straight from login-time instrumentation, the percentage of remaining old-format hashes over time (a curve that flattens out says the lazy path has done everything it's going to do on its own), and the last-login timestamp distribution across old-format accounts, which shows how stale that remaining tail actually is.

Removal itself should wait on explicit criteria. The old-format hash count needs to fall below a defined threshold, a small fraction of total accounts rather than a round number picked for comfort. The remaining old-format accounts need to have gone quiet for a defined window, many months of no login activity. And a forced-reset path needs to already be in place for anyone who tries to authenticate against an old hash after the fallback gets removed, so that removal doesn't turn into a lockout for whoever logs back in the week after. A plateau in rehash events that appears early in the migration is far more likely to be a bug in the detection or update logic than genuine saturation, and treating it as the latter delays fixing a real problem, so set up an alert for stalls, too.

None of this is a one-time project that gets closed out and forgotten. Clio's approach of dynamically incrementing the cost factor every 18 months is one formalization of building that review into the system itself, though it requires keeping server CPU capacity ahead of the schedule. A yearly review calendared in advance accomplishes the same goal with less automation and more discipline.

A pepper, a secret value stored outside the database entirely, in a secrets manager rather than alongside the hashes it protects, is one layer worth adding on top of all of this, separate from the migration itself. It doesn't replace anything described here, but it does mean a database-only leak doesn't hand an attacker everything needed to start cracking immediately. And before anyone flips the switch to remove the legacy verification path for good, the last step isn't checking a dashboard metric. It's querying the database directly and confirming, in the actual data, that zero rows with old-format hashes remain. The metric is a signal. The query is the proof.