Guides

How to Migrate Password Hashes Safely

Dr. Anika Rhodesยทยท8 min read

Every few months someone files a ticket that reads, more or less: "We're on an old hashing scheme, please re-hash all the stored passwords to bcrypt over the weekend." It is the most reasonable-sounding request in security, and it is impossible. You cannot re-hash a password table. If you could, so could the attacker who steals it.

That sounds like a dead end, but it isn't โ€” the migration simply has to happen somewhere other than where people expect it to. Here is why the obvious move is off the table, and the pattern that actually works.

Why you can't just re-hash the column

A password hash is deliberately one-way. When the user first set their password, you ran it through the hash function and stored the output. You never stored the password itself โ€” that is the entire point of hashing. So when you sit down to "upgrade" the table, you are holding a column of sha256(password) values and no passwords to feed into bcrypt.

The only operation available to you is to hash the hashes, and that protects nothing you care about. If you want the longer version of why fast hashes like SHA-256 were the wrong choice for passwords to begin with, bcrypt vs SHA-256 for password storage and MD5 vs SHA-256 vs bcrypt both lay out the reasoning.

Think of it as re-keying a building

Picture an apartment building whose locks you want to replace. You can swap every lock in the lobby overnight, but you cannot magically change the key in each tenant's pocket โ€” you only get to hand someone a new key when they next walk up to the door. A password migration carries exactly that constraint. The plaintext password exists for one brief moment, in your hands, when the user types it to log in. That single moment is your entire opportunity, so the whole strategy is built around catching it.

The core pattern: upgrade on login

When a user signs in, your code already does one thing: it checks the typed password against the stored hash. The migration slips in right after that check succeeds, while the plaintext is still in memory:

on login(username, password):
    record = load(username)
    if not verify(password, record.hash):   # uses the OLD scheme
        return login_failed
    # password is correct โ€” and we are holding the plaintext right now
    if needs_upgrade(record.hash):
        record.hash = bcrypt(password, cost=12)   # the NEW scheme
        save(record)
    return login_ok

The user notices nothing โ€” they log in as usual. Behind the scenes, their row quietly converts from the old scheme to bcrypt. Over the following weeks, as your active users sign in, the table migrates itself. You can watch the proportion of upgraded rows climb and know exactly how far along you are. You can generate and inspect a real bcrypt hash with the bcrypt hash generator, and confirm a password still verifies against one using the bcrypt compare tool while you build this out.

The gap: users who never log in

Upgrade-on-login has one honest weakness. A dormant account โ€” someone who signed up two years ago and never came back โ€” never triggers the upgrade, so its weak hash would sit there indefinitely. If an attacker dumps the table, those are the rows they crack first.

The fix is to harden the whole table up front, in a single batch job, by wrapping each old hash inside a strong one:

for each record:
    record.hash = bcrypt(record.old_hash, cost=12)
    record.scheme = "bcrypt(sha256)"   # remember it is wrapped

Now every row โ€” active or dormant โ€” is protected by bcrypt immediately, because an attacker would have to break bcrypt before they even reach the fast inner hash. At each user's next login you verify through the wrapper (bcrypt_verify(sha256(password), record.hash)), and then replace it with a clean bcrypt(password) so you are not stacking layers permanently. This is the same defense-in-depth instinct behind adding a pepper to password hashes: make a stolen database insufficient on its own.

Track which scheme each row uses

Both patterns above depend on your verify step knowing what it is looking at. Lean on self-describing hashes wherever you can: a bcrypt hash begins with $2b$ and encodes its own cost factor, so you can read the scheme straight off the string. Legacy hashes like raw MD5 or SHA-256 are just fixed-length hex with nothing to identify them, so if those are what you are migrating from, keep an explicit scheme column next to the hash.

That identifier also future-proofs you. Migration isn't only about swapping algorithms โ€” it's about staying ahead of hardware. A bcrypt cost factor you chose years ago is now too cheap, so needs_upgrade() should also return true when the stored cost is below your current target (aim for roughly 250 ms per hash on production hardware). The same upgrade-on-login hook then bumps the cost over time with no separate project. This is just key stretching applied as an operational habit rather than a one-time setting, and it's why Argon2 and bcrypt both expose their work factors as tunable parameters.

Don't reach for a mass reset

The tempting shortcut is to expire every password and email everyone a reset link. Resist it unless you have real evidence the hashes were already exposed. A blanket forced reset generates a wave of support tickets, trains your users to click password-reset links in email (which is exactly the muscle memory phishing relies on), and locks out anyone who can't act right away โ€” all while the old weak hashes still live on in backups, replicas, and logs. Upgrade-on-login reaches the same destination silently and keeps forced resets in reserve for what they are actually for: breach response.

The short version

You can't re-hash a password table because you threw the passwords away on purpose โ€” and that was the right call. Migrate at the one moment the plaintext comes back: on login. Wrap the dormant rows in bcrypt up front so nothing is left exposed, record the scheme so your code knows what it's verifying, and let the table convert itself as people sign in. When you're choosing the target algorithm and cost for the new scheme, bcrypt, scrypt, and Argon2 are all defensible โ€” the parameters matter more than the name, and a strong randomly generated password on top of a strong hash is what actually keeps the table uncracked.

Try the tools

Frequently Asked Questions

Can I just re-hash all the passwords in my database?

No. A password hash is one-way by design โ€” you stored the hash, never the password โ€” so there is no plaintext to feed into a new algorithm. The only thing you could do is hash the existing hashes, which adds no real strength and traps you verifying through two layers forever. The password only reappears for a moment when the user next logs in, and that is the one time you can upgrade their stored hash.

What is upgrade-on-login (rehash on verify)?

It's the standard migration pattern. When a user signs in, you verify their typed password against the old stored hash. If it matches, you now hold the plaintext for that single request, so you immediately re-hash it with the new algorithm (or new cost factor) and overwrite the stored value. The user notices nothing, and the table converts itself to the new scheme as people log in.

What about users who never log in again?

Those rows never trigger an upgrade-on-login, so their weak hashes would linger indefinitely. The fix is to wrap every old hash inside a strong one up front โ€” for example compute bcrypt of the existing hash, bcrypt(old_hash) โ€” in a batch. The whole table is now protected by bcrypt immediately, even for dormant accounts. At each user's next login you verify through the wrapper, then replace it with a clean hash of the real password.

How do I know which algorithm a given hash uses?

Prefer self-describing hashes. A bcrypt hash starts with $2b$ and encodes its cost factor; Argon2 and scrypt have similar labeled formats. Legacy hashes like raw MD5 or SHA-256 are just fixed-length hex, so if you store those, keep an algorithm column alongside the hash. Your verify routine reads the identifier, checks against the right scheme, and decides whether the hash needs upgrading.

Should I force everyone to reset their password instead?

Only if you have evidence the hashes were already stolen. A blanket forced reset is a poor migration tool: it generates support load, trains users to click reset links (a phishing risk), locks out people who can't act immediately, and still leaves the old weak hashes in backups and logs. Upgrade-on-login achieves the same end state silently, and you reserve forced resets for genuine breach response.

DA

Dr. Anika Rhodes writes for CodeUtilityKit, where the team builds free, privacy-first developer tools that run entirely in your browser. Every guide is written and reviewed by developers who use these tools daily.