skip to content

You inherit a forum whose users table stores raw MD5 password hashes; how do you migrate it to password_hash() without resetting everyone's password?

level: seniorimportance: should knowfreq 38%

answer

  1. raw md5 is not a password_hash string
  2. you lack plaintext, so migrate on login
  3. optionally wrap: password_hash(md5 digest)
  4. verify old scheme, then rehash plaintext
  5. track the scheme per row

basics

~20 s

You cannot batch-convert MD5 hashes because you lack the plaintext. Migrate on login: detect a legacy row, verify with the old scheme, then rehash the plaintext with password_hash and store it. Optionally wrap every existing MD5 in password_hash immediately so all rows get a slow layer at once.

solid answer

~50 s

Raw `md5($password)` is a 32-char hex string, not a self-describing `password_hash` string, so `password_verify` and `password_needs_rehash` do not understand it and you cannot batch-upgrade — you have no plaintext to feed a strong hash. Two combinable strategies. **(1) Migrate on login.** Add a column recording each row's scheme. On login, if the row is legacy, verify with `hash_equals(md5($input), $stored)`, and on success call `password_hash($input, PASSWORD_DEFAULT)` and overwrite the row (marking it modern). Active users migrate transparently; dormant rows stay MD5 until they log in. **(2) Wrap now for defence in depth.** Immediately run `password_hash($existingMd5Digest, PASSWORD_DEFAULT)` over every row, so even un-logged-in accounts gain a slow, salted layer over their weak digest; store a `bcrypt(md5)` marker. On login you then `password_verify(md5($input), $wrapped)`, and rehash to a direct `password_hash($input)` afterwards. The wrap fits bcrypt's 72-byte limit trivially (a 32-char hex digest). Never email everyone a forced reset unless the table is already exposed — the migration is invisible to users.

go deeper

for a junior

Know that you can't rehash without the plaintext, so migrate on login rather than in a batch.

for a middle

Explain that MD5 isn't a password_hash string, and the verify-old-then-rehash-plaintext flow with a scheme marker.

for a senior

Design the wrap-vs-login-only tradeoff, use hash_equals for the legacy compare, and phase out the legacy branch with telemetry.

for a principal

Decide whether dormant-account exposure warrants wrapping or a reset, and own the migration's risk and rollout.

## Why you cannot just re-hash the column `password_hash`/`password_verify` work on a **self-describing** string (`$2y$…`, `$argon2id$…`) that carries the algorithm, cost and salt. A legacy value like `5f4dcc3b5aa765d61d8327deb882cf99` is a **bare MD5 digest** — no algorithm marker, no salt, no cost — so `password_verify` returns `false` on it and `password_needs_rehash` cannot classify it. And because a hash is one-way, there is **no plaintext** in the database to feed into a strong function. So a one-shot `UPDATE ... SET hash = password_hash(...)` is impossible; migration must happen where the plaintext appears, or by wrapping the existing digest. ## Strategy 1 — migrate opportunistically on login Add a small marker so the code knows how to verify each row (a `hash_scheme` enum, or detect by shape: a 32-hex string is legacy, a `$`-prefixed string is modern). 1. User submits `$input`. 2. If the row is **legacy**: `if (hash_equals($storedMd5, md5($input))) { … }` — use `hash_equals` to avoid a timing/loose-comparison bug. 3. On success, upgrade in place: `$new = password_hash($input, PASSWORD_DEFAULT); UPDATE … SET hash = $new, hash_scheme = 'modern'`. 4. If the row is already **modern**: normal `password_verify` + `password_needs_rehash` path. Active accounts convert on their next login; the code path for legacy rows can be deleted once telemetry shows none remain. ## Strategy 2 — wrap the digests immediately Opportunistic migration leaves dormant accounts as weak MD5 for as long as they stay logged out. If that risk is unacceptable, **pre-wrap** every row in one batch: ```php // one-time batch, per row: $wrapped = password_hash($existingMd5Hex, PASSWORD_DEFAULT); // bcrypt over the 32-char digest // store $wrapped, mark scheme = 'bcrypt(md5)' ``` Now every stored value is a slow, salted bcrypt hash, so a future dump is no longer a pile of instantly-crackable MD5s. Login for a wrapped row becomes: ```php if (password_verify(md5($input), $wrapped)) { // authenticated; now upgrade to a direct hash of the plaintext $direct = password_hash($input, PASSWORD_DEFAULT); // store $direct, mark scheme = 'modern' } ``` The 32-character hex digest is far under bcrypt's 72-byte limit, so wrapping is safe. The wrapped hash's effective strength is still bounded by MD5's weaknesses against the *original* password, which is why you still upgrade to a direct hash on login — the wrap is a bridge, not the destination. ## The decision and the traps | Question | Guidance | |---|---| | Force a mass password reset? | Only if the MD5 table is already believed leaked; otherwise migrate silently — a forced reset is user-hostile and unnecessary. | | Wrap now, or login-only? | Wrap when dormant accounts are a real exposure; login-only when simplicity matters and accounts are active. | | How to compare the MD5? | `hash_equals`, never `==` — a bare digest can trigger the magic-hash loose-comparison bypass. | | How to know the scheme? | An explicit column beats sniffing by string shape, which breaks if formats overlap. | - **Don't** keep verifying MD5 forever — set the code to phase out the legacy branch and monitor how many rows remain. - **Don't** compare the digest with `==` — a `0e…` MD5 is a bypass (owned by the sinks leaf; the fix here is `hash_equals`). - The *theory* of why MD5 is unfit and what a KDF must provide is owned by the security concept homes; the PHP mechanics are that `password_hash` is self-describing, MD5 is not, and both wrapping and login-migration are expressed with `password_hash`/`password_verify`.

  • Why compare the legacy MD5 with `hash_equals` instead of `==`?
    A bare MD5 hex digest can be a magic hash (a `0e…` string), and `==` juggles two such strings to the number 0, so an attacker could bypass the check. `hash_equals($storedMd5, md5($input))` compares byte strings in constant time and does no juggling, closing both that bypass and a timing side channel.
  • What does wrapping (`password_hash` over the MD5 digest) actually buy you?
    It gives every row — including dormant accounts — a slow, salted bcrypt layer immediately, so a database dump is no longer a set of instantly-crackable MD5s. It does not repair MD5's weakness against the original password, which is why you still upgrade to a direct hash of the plaintext when the user next logs in.
  • When is a forced password reset the right call instead?
    When you have reason to believe the MD5 table has already leaked, since then the weak hashes may already be cracked and silent migration protects nobody. Absent evidence of exposure, silent migration (with or without wrapping) is preferred because it is invisible to users and avoids a support burden.

saying these in an interview costs you the question

  • you can batch-convert MD5 hashes to bcrypt directly
  • password_verify works on a raw MD5 hex string
  • always force every user to reset their password
  • compare the stored MD5 with == during migration
  • wrapping the MD5 makes it as strong as hashing the plaintext
  • password_needs_rehash can detect a legacy MD5 row