DevTools Hub

Search tools

Search for a developer tool

Password Hash Migration Strategies

Part of the Hashing Toolkit

Password Hashing Best Practices already covers the core mechanism — rehash on login — in three bullet points, and Why SHA-256 Should Not Be Used for Passwords covers one specific version of it in detail. This is the broader version: every real migration is some combination of a handful of strategies, which one (or which combination) depends entirely on which of three questions is hardest in your situation — what happens to users who log in again soon, what happens to the ones who don't, and how login verification works at all while your table holds a genuine mix of old and new hashes at the same time.

The one constraint every strategy works around

You cannot recompute a stronger hash for a password you don't have — hashing is one-directional by design, and that's exactly the property that makes it useful in the first place. The only moment you ever have the real plaintext again is the instant a user successfully authenticates with it, briefly, in server memory, for that one request. Every strategy below is a different answer to "what do we do with that one moment," and a different answer to "what do we do about the users who never give it to us again."

Strategy 1: Rehash on login

The default, and usually sufficient on its own for a same-system upgrade — a cost-factor bump, or swapping bcrypt for Argon2id. On every successful login, check whether the stored hash's embedded algorithm and parameters match your current target; if not, recompute from the plaintext you already have for this request and overwrite the row:

function verifyAndMaybeRehash(password, storedHash, userId) {
  if (!verify(password, storedHash)) return false;   // library's own verify(), always

  if (needsRehash(storedHash, currentTarget)) {
    const newHash = hash(password, currentTarget);
    db.updatePasswordHash(userId, newHash);           // fire-and-forget is fine here
  }
  return true;
}

needsRehash is a comparison against whatever your library exposes for reading back a hash's embedded algorithm/version/cost — bcrypt and Argon2id both encode this directly in the stored string ($2b$12$..., $argon2id$v=19$m=19456,t=2,p=1$...), so this check is normally a few lines, not a schema change. Password Hash Inspector decodes any hash you paste back into exactly these fields if you want to confirm what a given row actually contains.

The honest tradeoff: this only migrates accounts that log in. Your table will hold a real mix of old and new hashes for a while — expected, not a bug — and some fraction of accounts may never log in again to trigger it at all. That long tail is Strategy 4's problem, not this one's.

Strategy 2: Wrap immediately, migrate later

Use this when the current hash is bad enough that waiting for logins to trickle in isn't acceptable — a bare SHA-256 or MD5 column, discovered in an audit, still sitting in production today. You don't have the plaintext, but you don't need it: run a one-off batch job that computes bcrypt(existingHash) for every row, and change verification to hash the login attempt with the old algorithm first, then bcrypt-compare against the wrapped value. This runs tonight, needs no user cooperation, and gets every account behind a slow hash immediately — before a single person logs in again.

It's a bridge, not a destination: the inner hash is still whatever weak thing it was before, so this alone doesn't fix per-account weaknesses the way a real algorithm migration does — it just makes the whole table expensive to attack wholesale. Layer Strategy 1 on top once it's in place, so active accounts still migrate to a genuine, unwrapped Argon2id/bcrypt hash over time.

Strategy 3: Verifying logins during the transition

Strategies 1 and 2 both leave you with a table holding more than one hash format at once — which means verify() itself has to know how to check either. When every format in play is self-describing (bcrypt, Argon2id, and PBKDF2's modular-crypt encodings all embed enough to identify themselves), a dispatcher is often the entire mechanism:

function verify(password, storedHash) {
  if (storedHash.startsWith("$argon2id$")) return argon2Verify(password, storedHash);
  if (storedHash.startsWith("$2b$") || storedHash.startsWith("$2a$")) return bcryptVerify(password, storedHash);
  if (storedHash.startsWith("$pbkdf2-sha256$")) return pbkdf2Verify(password, storedHash);
  throw new Error(`Unrecognized hash format: ${storedHash.slice(0, 12)}`);
}

A format that isn't self-describing — a bare hex digest from a legacy column, for instance — needs an explicit signal instead: a hash_algorithm column, or at minimum a length-based heuristic (32 hex characters is MD5, 64 is SHA-256) as a stopgap until Strategy 2 wraps every row into a self-describing format anyway.

Strategy 4: Forced reset

The strategies above all avoid forcing a reset because it's disruptive — real users forget passwords, abandon the reset flow, and generate support load. But it's the right call, not a failure, in three specific situations: a confirmed or suspected breach of the password table itself (don't trust rehashing an already-compromised value — reset it); a regulatory or contractual requirement with a hard deadline gradual migration can't guarantee; and the long tail Strategy 1 can't reach — accounts that stay inactive past a reasonable window (many teams use 6–12 months) and never trigger a rehash on their own. OWASP's own guidance treats this last case as the expected fallback, not an edge case to design around.

Migrating to a different system entirely

Everything above assumes one codebase upgrading its own hashing. Moving to a managed identity provider — Auth0, AWS Cognito, Okta, Firebase Auth — is the same problem across a system boundary instead of within one, and the major providers ship a purpose-built mechanism for exactly this: Auth0's Custom Database connections support a migration script that runs your legacy verification against the old hash on a user's first login through the new system, then Auth0 stores its own hash going forward. AWS Cognito's Migrate User Lambda trigger does the equivalent — invoked on a failed sign-in against the new user pool, given the chance to verify against your old system and, on success, hand Cognito the plaintext once to hash and store natively. Both are rehash-on-login (Strategy 1), just implemented as a hand-off between two systems instead of two code paths in one.

Common mistakes

  • Trying to convert a hash without plaintext. There's no operation that turns a bcrypt hash into an Argon2id hash of the same password — hashing the old hash string as if it were the password produces a hash of a hash, not a migrated password, and locks every affected user out.
  • Removing the old verify path too early. The single most common real incident: shipping the new hashing code, forgetting the dispatcher still needs to recognize the old format, and locking out every account that hasn't rehashed yet — which on day one of a rehash-on-login rollout is all of them.
  • Reaching for a forced reset by default. It's the right tool for a breach or a hard deadline, and the wrong one for a routine cost-factor bump — that's what rehash-on-login exists for, at a fraction of the support cost.
  • Not tracking migration progress. Without a query on the stored algorithm/version — trivial if you followed Strategy 3's self-describing formats — there's no way to know when it's actually safe to delete the old verification code path, or how large the inactive-account tail really is before deciding on Strategy 4.

Which strategy fits your situation

SituationStrategy
Raising a cost factor, or swapping bcrypt for Argon2idRehash on login (1)
A bare SHA-256/MD5 column found in production todayWrap immediately (2), then rehash on login (1)
Moving to Auth0, Cognito, Okta, or similarThe provider's migration-trigger mechanism (1, across systems)
Confirmed or suspected database breachForced reset (4) — don't rehash a compromised value
Accounts inactive well past your migration windowForced reset (4), as the expected cleanup step

FAQ

How long does a rehash-on-login migration actually take?

However long your inactive-account tail is — active daily users migrate within a day, weekly users within a week, and the distribution follows your actual login frequency, not a fixed timeline. Track it with a query against the stored algorithm/version rather than guessing.

Can I speed it up by emailing users to ask them to log in?

Yes, and plenty of teams do exactly this shortly before a forced-reset deadline — it's a legitimate way to shrink the tail before falling back to Strategy 4, not a substitute for having one.

Does a mass rehash event overload the server?

Rehash-on-login spreads the cost across real login traffic over time, so it rarely does. A forced-reset event concentrated into a short window is different — if you're estimating what a cost factor costs at scale, Bcrypt Capacity Planner turns a measured per-hash time and your user count into the aggregate CPU impact.

Try it yourself

Password Hash Inspector decodes an existing hash's algorithm and parameters — the input Strategy 1's needsRehash check and Strategy 3's dispatcher both depend on. Hash Comparison helps pick the target algorithm if you haven't already, and Password Hash Generator computes real hashes in the target format so you can confirm what a migrated row should look like before you ship anything. All three run entirely in your browser.

Related tools