Short answer: you cannot convert existing hashes, because you cannot read the passwords. Instead, accept the old hash once at login, and re-store it with password_hash() at the moment you have the plain password in memory. Users migrate silently as they sign in. Nobody resets anything.

Why MD5 is a problem

MD5 was designed to be fast. For passwords that is precisely wrong — fast means an attacker can try billions of guesses per second on ordinary hardware.

Worse, MD5 as usually implemented has no salt. Identical passwords produce identical hashes, so an attacker does not attack each account separately. They compare against a precomputed table and recover the common ones instantly.

If that database leaks, most accounts are readable within minutes. And because people reuse passwords, the damage reaches other services.

Why you cannot just convert them

The obvious idea is to loop over the users table and rehash everything.

You cannot. A hash is one-way — the MD5 string does not contain the password. Wrapping it (password_hash(md5($password))) is worse than useless: you inherit MD5’s weakness and make the password permanently unrecoverable for a proper upgrade later.

The plain password exists in exactly one place: memory, for a moment, during login. That is where the upgrade has to happen.

Step 1: widen the column first

Do this before deploying any code. This is the step that breaks things if you skip it.

MD5 hashes are 32 characters. Bcrypt is 60. If the column is still VARCHAR(32), MySQL truncates every new hash silently — and a truncated hash never verifies. Every user who logged in successfully would be locked out, with no error explaining why.

ALTER TABLE users MODIFY password VARCHAR(255) NOT NULL;

255, not 60 — it costs nothing on VARCHAR and leaves room for whatever PASSWORD_DEFAULT becomes in future PHP versions.

Step 2: change registration and reset

The easy part.

// before
$hash = md5($password);

// after
$hash = password_hash($password, PASSWORD_DEFAULT);

Use PASSWORD_DEFAULT rather than naming an algorithm. When PHP moves the default to something stronger, your new hashes follow automatically.

Step 3: the login, which does the actual work

Old logins usually compare the password inside the SQL query:

// this cannot work with bcrypt
$stmt = $db->prepare("SELECT * FROM users WHERE email = ? AND password = ?");
$stmt->execute([$email, md5($password)]);

Bcrypt embeds a random salt, so the same password produces a different hash every time. There is nothing to match on. You must fetch by email, then verify in PHP:

$stmt = $db->prepare("SELECT * FROM users WHERE email = ? LIMIT 1");
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

$authenticated = false;

if ($user) {
    $stored = (string) $user['password'];

    if (password_verify($password, $stored)) {
        // already migrated
        $authenticated = true;

    } elseif (strlen($stored) === 32 && hash_equals($stored, md5($password))) {
        // legacy account: correct password, old hash.
        // We have the plain password right now — upgrade it.
        $authenticated = true;

        $upgrade = $db->prepare("UPDATE users SET password = ? WHERE id = ?");
        $upgrade->execute([password_hash($password, PASSWORD_DEFAULT), $user['id']]);
    }
}

Two details that matter:

strlen($stored) === 32 — only attempt the legacy path on something that actually looks like an MD5 hash. Without it you run a pointless MD5 comparison against every bcrypt hash on every login.

hash_equals() rather than === — it compares in constant time, so the comparison cannot leak information through how long it takes.

What users experience

Nothing. They log in as normal. The account is upgraded during that request.

That is the point: security migrations that force a password reset train users to expect reset emails out of the blue, which is exactly the habit phishing relies on.

Cleaning up the stragglers

Some accounts will never log in again. Check how many remain:

SELECT COUNT(*) FROM users WHERE CHAR_LENGTH(password) = 32;

Once that number stops falling — a few months, say — the remaining accounts are dormant. At that point you can invalidate those passwords and require a reset for the few people affected, then delete the legacy branch entirely.

Do not delete it before then, or you lock out everyone who has not been back yet.

While you are in there

Two things usually worth fixing at the same time:

  • Minimum password length. Legacy sites often allow four characters. Eight is a reasonable floor.
  • Rate limiting on login. Bcrypt makes offline cracking slow. It does nothing against someone trying passwords through your login form.

Inherited a codebase you are not sure about? Tell me what you are looking at — auditing and fixing other people’s code is a large part of what I do.

PHPSecurityPasswordsLegacy code
Written by

Amir Iqbal

Full-Stack Web & Mobile Developer. I build websites, web apps and mobile apps — and finish or fix the ones that got stuck. Around 1,500 completed projects with a 5.0 rating.

More about how I work