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. So when the industry moves on from your algorithm, or you simply want to raise a cost factor as hardware gets faster, your database is stuck with yesterday's hashes and no way to convert them offline. There's exactly one moment where that changes: the instant a user logs in and hands you the plaintext, briefly, to check it. Building a migration strategy around that one moment — rather than around a disruptive one-off event — was the actual design problem.
What is password hashing
Encryption is reversible by design — whoever holds the key can read the data. That's exactly wrong for passwords: no one, including the service itself, should ever be able to recover them. So passwords are hashed: a one-way function turns the password into a fixed string, and login means hashing the attempt and comparing.
But not any hash. General-purpose hashes like SHA-256 are built to be fast — and fast is fatal here, because an attacker with a stolen database can test billions of guesses per second. Password hashing algorithms are built to be deliberately slow and, in newer designs, memory-hungry:
- bcrypt (1999) — an adjustable cost factor; each +1 doubles the work. Still respectable.
- argon2 (2015, Password Hashing Competition winner) — costs CPU and memory, which cripples GPU cracking rigs.
Both bake a random salt into every hash, so two users with the same password get different hashes and precomputed rainbow tables are worthless. Everything — algorithm name, cost, salt, digest — is encoded in the stored string itself: $2b$12$N9qo8uLO.... That self-description is what makes multi-scheme verification, and the migration approach below, possible.
The alternatives
Say you launched on bcrypt and want new hashes on argon2 — or just want to raise bcrypt's cost factor. The options on the table were not great:
- Re-hash everything offline. Impossible — you don't have the passwords, only hashes. This isn't a trade-off, it's a hard constraint that rules the option out entirely.
- Force a global password reset. Technically clean and fully in the service's control, but experientially awful — every user punished with a forced reset for the service's own crypto hygiene, on a schedule that has nothing to do with them.
- Run both algorithms forever, unmanaged. Cheapest to ship, but entropy wins: five years later nobody knows which users are on which scheme, and the "temporary" dual-algorithm state never actually resolves.
- Lazy re-hash on login. Verify each login against whatever the stored hash says it is, and if that scheme is outdated, re-hash the plaintext you're briefly holding anyway and save it. No forced event, no offline impossibility, and the migration has a real endpoint — the day the old hashes run out.
The decision
The lazy re-hash won, and the reason it works cleanly comes down to how a password-hashing library like passlib's CryptContext is configured. Its whole policy fits in one declaration: a list of accepted schemes, with the first entry marking the current standard for new hashes and every other entry still accepted but flagged as 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 from the stored hash string itself, verifies with the right one, and — if the hash is deprecated, or just below the current cost parameters — returns a fresh hash under the current scheme in the same call. The return shape is the elegant part: (True, None) means "valid, already current — do nothing." (True, "$2b$...") means "valid, and here's its upgrade." Two values, whole policy communicated.
The upgrade is wired into the one place the plaintext briefly exists: the login check itself. On a successful login, if a new hash comes back, the stored hash gets overwritten in the same transaction as the login; on a failed login, nothing changes. No new code path holds the plaintext for longer than it always did — the migration adds zero exposure.
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.
One subtlety worth calling out: why put the older algorithm first in the scheme list rather than the newer one? Because list order is a policy dial, not a ranking of algorithm age. If bcrypt is still the organization's accepted standard and argon2 is merely tolerated for old hashes, bcrypt goes first. The day that standard changes, flipping the order is the entire deployment — every user on the old scheme migrates silently on their next login, no forced reset, no email campaign.
Two caveats shaped how this gets operated safely rather than left to chance. First, dormant users never upgrade — the migration only reaches people who actually log in, so accounts idle for years keep old hashes, and if the old scheme were ever truly broken those accounts would still need a forced reset in the end. Second, a scheme can never be dropped from the accepted list while any user still has a hash under it — pruning the list is only safe once the data confirms that scheme is actually gone from the user base, not on a timeline picked in advance.
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, is free.