Open an established PHP codebase and there is a fair chance the login still looks like this:
$hash = md5($password . $salt);
Somebody wrote that carefully. They read that plain md5 was broken, added a salt, and stopped. It was reasonable advice in 2009 and it has been wrong for about fifteen years, for a reason that is not obvious from the outside: the problem was never that md5 lacked a salt. The problem is that md5 is fast.

Fast is the vulnerability
A hash function for file integrity should be fast. A hash function for passwords must be slow, and that inversion is the whole subject.
Nobody attacks a stolen password table by reversing the hash. They guess. They take a list of common passwords, hash each one with your salt, and compare. The only thing standing between them and the plaintext is how many guesses per second they can make.
A consumer graphics card does billions of md5 hashes a second. Against a table of md5 hashes with per-user salts, the salts stop precomputed rainbow tables and nothing else — the attacker simply runs the guesses per user. Any password on a common wordlist falls in minutes.
bcrypt is deliberately slow, and its cost is adjustable. At the default work factor a single hash takes tens of milliseconds. That is imperceptible on a login page and it drops the attacker from billions of guesses a second to a few thousand. The same wordlist that fell in minutes now takes years.
That is the entire argument. Not “md5 is broken” in the abstract — it is that md5 is the wrong tool, and no amount of salting turns a fast hash into a slow one.
What password_hash actually does
PHP has had the correct answer built in since 5.5, and it is one line.
$hash = password_hash($password, PASSWORD_DEFAULT);
Three things happen there that people then go and undo by hand.
It generates its own salt, cryptographically random, per password. You do not supply one. The $salt option that used to exist was deprecated and then removed, because every codebase that used it did so worse than the default.
It stores everything in the output string. The result looks like $2y$12$Nv3K... and it encodes the algorithm, the cost, the salt and the hash. One column, VARCHAR(255), no separate salt column. If you have a salt column in your users table, that is the sign of a hand-rolled scheme.
It tracks what the current default should be. PASSWORD_DEFAULT is bcrypt today. When PHP changes it, your new hashes change with it, which is why you should not pin PASSWORD_BCRYPT unless you have a specific reason.
Verification is the matching line:
if (password_verify($password, $user['password_hash'])) {
// correct
}
Note what is absent. You do not hash the input and compare strings. password_verify reads the algorithm and cost out of the stored hash, applies them, and compares in constant time. Doing it yourself gets all three of those wrong.

Argon2, and whether you need it
PHP also offers PASSWORD_ARGON2ID, which won the Password Hashing Competition and is the better algorithm on paper. It is memory-hard as well as slow, which matters because the attacker’s advantage is specialised hardware, and memory is the thing that is expensive to parallelise.
Whether you should switch is a narrower question than the internet suggests. bcrypt at a sensible cost is not broken and is not the weak point in any real application. If you are starting fresh and your hosting gives you the memory, Argon2id is the better default. If you have a working bcrypt implementation, moving to Argon2id is a reasonable improvement and a long way down the list from having any of it in the first place.
The one thing that is not optional either way: pick the cost deliberately.
Choosing a cost
bcrypt’s cost is a power of two. Cost 12 is roughly four times the work of cost 10. The right value is whatever takes about 100 to 250 milliseconds on your production hardware — fast enough that nobody notices, slow enough to hurt.
Measure it rather than copying a number:
foreach ([10, 11, 12, 13] as $cost) {
$t = microtime(true);
password_hash('benchmark', PASSWORD_BCRYPT, ['cost' => $cost]);
printf("cost %d: %.0f msn", $cost, (microtime(true) - $t) * 1000);
}
Run that on the server, not your laptop. Shared hosting is frequently slower than a development machine, and a cost that is comfortable locally can add half a second to every login in production.
Then write the number down somewhere, because the correct cost rises as hardware gets faster. Revisiting it every couple of years is the maintenance this has.
Migrating a table full of md5
The question everyone asks next: we have forty thousand md5 hashes and no plaintext. How do we move?
You cannot convert them. You do not have the passwords. What you can do is upgrade each one the next time that user logs in, because that is the one moment the plaintext passes through your code.

function verify_and_upgrade(string $password, array $user, PDO $db): bool
{
// New-format hashes start with $2y$ or $argon2
if (str_starts_with($user['password_hash'], '$')) {
if (!password_verify($password, $user['password_hash'])) {
return false;
}
// Cost or algorithm may have moved on since this was written
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
store(password_hash($password, PASSWORD_DEFAULT), $user['id'], $db);
}
return true;
}
// Legacy md5 + salt
if (!hash_equals($user['password_hash'], md5($password . $user['salt']))) {
return false;
}
store(password_hash($password, PASSWORD_DEFAULT), $user['id'], $db);
return true;
}
Over a few months most active accounts convert themselves. What remains are dormant accounts, and those are the ones worth thinking about: they are the accounts most likely to be abandoned, most likely to reuse a password from elsewhere, and least likely to be noticed if taken. Set a date, force a reset on whatever has not logged in by then, and clear the column.
Note password_needs_rehash in there. It is the same mechanism and it is what keeps the table current after the migration — raise the cost in one place and every login quietly upgrades its own row.
The comparison that leaks
Compare the legacy branch above with what most code does:
if ($user['password_hash'] === md5($password . $user['salt'])) { // leaks
PHP’s === on strings stops at the first byte that differs. A hash that matches the first six characters takes measurably longer to reject than one that differs immediately. Over enough requests, that difference is enough to recover the correct value a byte at a time.
hash_equals compares every byte regardless, so the time taken does not depend on how much of the input was right. Use it anywhere you compare a secret to a user-supplied value — password hashes in legacy paths, API tokens, password reset tokens, webhook signatures.
It is worth being clear about the scale of the risk. Timing attacks over the internet are difficult, and network jitter hides a lot. This is not the hole somebody walks through tomorrow. It is also a one-word change, and there is no argument for the leaky version.
Pepper, and why it is usually a distraction
Every discussion of this reaches somebody suggesting a pepper: a secret value, the same for all users, mixed in before hashing and stored outside the database. The argument is that an attacker who dumps the table still cannot guess, because they are missing a value that was never in it.
The argument is sound and the practice is usually not worth it, for one reason: the pepper has to live somewhere the application can read, which in almost every deployment means a config file on the same server as the database credentials. An attacker who got the table through SQL injection did not get the file — so it helps there. An attacker who got shell access got both, and it helped with nothing.
If you do want one, the way that does not break is hash_hmac before the hash, never string concatenation:
$peppered = hash_hmac('sha256', $password, $pepper);
$hash = password_hash($peppered, PASSWORD_DEFAULT);
And understand what you have signed up for. Rotating that pepper means every stored hash is invalid and every user needs a reset. Losing it means the same. That is a real operational cost for a benefit that only applies to one class of breach.
For most applications the honest ranking is: get off md5 first, set the cost deliberately second, rate limit the login third, and consider a pepper somewhere well after all of those.
Where the breach usually actually comes from
Worth saying, because password hashing attracts more attention than its share of real incidents.
In the compromises that actually happen to small applications, the password table is rarely the way in. It is a reused admin password, an unpatched plugin, a forgotten staging site sharing the production database, or credentials committed to a repository. Hashing does nothing about any of those.
What hashing does is bound the damage after the fact. When a table does get out — and for a long-lived application it eventually might — the difference between md5 and bcrypt is the difference between every account being compromised within the day and almost none of them being worth the electricity. It is insurance, and it is cheap insurance, but it is not a substitute for the locks on the door.
Three things that are still wrong in 2026
A maximum password length of 12 characters. Usually a leftover from a VARCHAR(12) column that no longer exists. A bcrypt hash is always 60 characters regardless of input, so the column does not care. Reject nothing under 72 bytes — that is bcrypt’s own input limit and worth knowing about.
Composition rules. One uppercase, one number, one symbol produces Password1! across half your user table. Length is what matters. A 16-character passphrase beats an 8-character jumble by an enormous margin and people can actually remember it.
Hashing on the client. Somebody always suggests hashing in JavaScript before sending. It changes nothing: the hash becomes the password. Anyone who intercepts it logs in with it. Use HTTPS and hash on the server.
What this leaves you
Two functions, one column, one number to revisit occasionally. That is the whole of it, and it is less code than any hand-rolled scheme it replaces.
If you take one thing away: the salt was never the point. Slowness is the point, and password_hash is how PHP gives it to you.
Worth reading next if this applies to you: rate limiting the login itself. Hashing protects the table after a breach; it does nothing against somebody guessing passwords against your live form at speed, and those are separate problems with separate answers.



