The problem
You can't decrypt a password hash to re-hash it with a stronger algorithm — that's the entire point of a hash.
When the industry moves on from your algorithm, or you just want to raise a cost factor as hardware gets faster, the database is stuck with yesterday's hashes with no way to convert them offline.
There's exactly one moment that changes: the instant a user logs in and hands you the plaintext, briefly, to check it. The real design problem was building a migration strategy around that one moment, instead of around a disruptive one-off event.
This is really a decommissioning problem as much as an upgrade one: an old hashing scheme is a security liability sitting in the database until every last user is off it.
What is password hashing
Encryption is reversible — whoever holds the key can read the data. That's exactly wrong for passwords, so they're hashed instead: a one-way function, checked by re-deriving the same hash from a login attempt and comparing.
General-purpose hashes like SHA-256 are built to be fast, which is fatal here — an attacker with a stolen database can test billions of guesses a second.
Password hashing algorithms are deliberately slow and, in newer designs, memory-hungry: bcrypt (adjustable cost factor, still respectable) and argon2id (costs CPU and memory, raising the cost of GPU cracking specifically).
Both bake a random salt into every hash, and everything — algorithm, cost, salt, digest — is encoded in the stored string itself, which is what makes multi-scheme verification possible.
The alternatives
- Re-hash everything offline. Impossible — you don't have the passwords, only hashes. A hard constraint, not a trade-off.
- Force a global password reset. Clean and fully in the service's control, but every user gets punished with a forced reset for the service's own crypto hygiene.
- Run both algorithms forever, unmanaged. Cheapest to ship, but the "temporary" dual-algorithm state never actually resolves.
- Lazy re-hash on login. Verify against whatever scheme the stored hash says, and if it's outdated, re-hash the plaintext you're briefly holding anyway. No forced event, and the migration has a real endpoint.
The decision
The lazy re-hash won. Python's passlib library has this built in: its CryptContext class makes the whole policy one declaration — a list of accepted schemes, the first marking the current standard, every other entry accepted but flagged deprecated.
pwd_context = CryptContext(
schemes=["bcrypt", "argon2"],
deprecated="auto",
)
def verify_and_update_password(plain_password, hashed_password):
valid, new_hash = pwd_context.verify_and_update(
plain_password, hashed_password
)
return valid, new_hash # new_hash is None if no upgrade needed
The library reads the algorithm off the stored hash, verifies with the right one, and — if that scheme is deprecated or below current cost parameters — returns a fresh hash in the same call. (True, None) means valid and already current; (True, "$2b$...") means valid, here's the upgrade.
The upgrade is wired into the one place the plaintext briefly exists: the login check. On success, if a new hash comes back, the stored hash is overwritten as part of that same login — no new code path holds the plaintext any longer than it always did.
The only moment you're ever allowed to re-hash a password is the moment someone proves they know it by typing it in — everything about this design follows from taking that moment seriously.
Decommissioning an old algorithm is then just reordering the list: schemes=["bcrypt", "argon2"] becomes schemes=["argon2", "bcrypt"]. Existing bcrypt users still verify fine on their next login, but bcrypt is now the deprecated entry, so that same login returns a fresh argon2 hash to store. Every user migrates silently, one login at a time — no forced reset, no email campaign.
Two caveats keep this from being decommissioned on faith. Dormant users never upgrade — the migration only reaches people who actually log in, so idle accounts keep old hashes indefinitely. Password-reset and password-change flows should hash under the current scheme too, so recovery counts as a migration.
And a scheme can never be dropped from the accepted list while any user still has a hash under it — that's only safe once the data confirms the legacy scheme is actually gone from the user base, which means tracking that proportion rather than guessing at a timeline.
The outcome
- The database converges toward the current algorithm one login at a time, with the user experiencing nothing different — no reset email, no forced re-authentication, no visible event at all.
- The cost of a future algorithm migration collapses to a one-line configuration change, because the upgrade machinery is already live and running on every login rather than something that has to be built under pressure the day an algorithm is declared broken.
- The investment is in having the pattern in place before it's needed; the actual migration, when it happens, requires no separate migration job to build, run, or babysit.
- Each rehash still costs its own CPU and memory, same as any other login, just spread across real traffic instead of a single offline batch.