Why sha256(password) Is a Breach in Slow Motion: Salt, Work Factor, and PASSWORD_DEFAULT
Two announcements, same week. Company A shipped md5($password) and, when the dump surfaced, thousands of accounts fell within hours — every hash is a fixed length, GPUs test billions of candidates per second, and the most common passwords are public lists. Company B's dump surfaced and the cracking bench produced almost nothing. The difference is not a cleverer algorithm; it is three decisions baked into password_hash():
$hash = password_hash($password, PASSWORD_DEFAULT);
// $2y$10$K9u1O0jX1kQm0R7xQe2uLu5y7Wc8v1Zq3n9pQ2sT4uV6wX8yZ0aBc — algorithm, cost, salt, digest
Read the string: $2y$ is bcrypt, $10$ is the cost, and the next 22 characters are a salt that was generated for this one password. What each decision buys:
Salt kills precomputation. Rainbow tables map hash → password once for millions of inputs; a per-password salt changes the input every time, so one rainbow table is worth exactly one account. password_hash() generates 16 random bytes itself and embeds them in the output — you never manage salts, which means you never reuse them, which is the classic hand-rolled failure (md5($salt . $password) with $salt from a config file).
Cost kills speed. Bcrypt runs the Blowfish key schedule 2^cost times — cost 10 is 1024 rounds, roughly 60–150 ms on a 2020s server CPU, and each +1 doubles that. Attackers buy GPUs and FPGAs precisely because fast hashes amortize; at ~100 ms per guess, an offline attacker's throughput collapses from billions per second to single-digit guesses per second per core. That asymmetry — your server pays it once per login, the attacker pays it per candidate, forever — is the entire economics of password storage.
| Stored as | Guessing cost | Result of a dump |
|---|---|---|
| plaintext | zero | instant, all accounts |
md5()/sha1()/sha256() |
microseconds; GPUs billions/sec | common passwords fall in minutes |
| fast hash + reused salt | microseconds | rainbow table covers everyone again |
password_hash(PASSWORD_DEFAULT) |
~100 ms + unique salt | common passwords only, at a trickle |
Two API points interviews ask directly. PASSWORD_DEFAULT is not "md5" or a constant you pick — it is the strongest algorithm PHP currently ships as the default (bcrypt, $2y$, today), and it may change in a future release, so your column must be sized for a hash you have not met yet: VARCHAR(255) fits bcrypt's 60 characters and Argon2id's ~96 with room to spare. PASSWORD_BCRYPT pins bcrypt explicitly and accepts tuning:
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]); // pinned + tuned
$hash = password_hash($password, PASSWORD_ARGON2ID, [ // PHP 7.3+, libsodium
'memory_cost' => 65536, // 64 MB per hash — memory-hard, GPU-hostile
'time_cost' => 4,
'threads' => 1,
]);
What you must never substitute: hash('sha256', $password) — fast, unsalted by itself, and "salt it yourself" only rebuilds a fast hash with extra steps; encrypt()/decrypt() style reversible storage, which turns one SQL read into a plaintext breach; and any home-made construction — hash_hmac with a pepper from the environment is a defensible addition (it survives a database-only theft), but the primary must remain a slow, salted password KDF. OWASP's password-storage guidance says the same thing: bcrypt, Argon2id, or PBKDF2 with a workload an attacker must pay per guess.
Next: what happens at login, where password_verify() decides in constant time and password_needs_rehash() quietly upgrades yesterday's cost to today's.
password_verify and Rehash-on-Login: Migrating a Legacy md5 Table Without a Reset
Verification is where the stored format proves itself:
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = :email');
$stmt->execute([':email' => $email]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
// one generic message for both branches — see the note below
http_response_code(401);
exit('Invalid email or password');
}
if (password_needs_rehash($user['password_hash'], PASSWORD_BCRYPT, ['cost' => 12])) {
$up = $pdo->prepare('UPDATE users SET password_hash = :h WHERE id = :id');
$up->execute([':h' => password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]),
':id' => $user['id']]);
}
session_regenerate_id(true); // new session id the moment privilege changes
Follow what password_verify() does with the stored string: it parses algorithm, cost, and salt out of $2y$10$…, recomputes the digest from your submitted password with those parameters, and compares — it never reverses anything, never returns or reconstructs the plaintext, and never decodes it. A malformed or truncated hash makes it return false rather than throwing, so a corrupted column fails closed as "wrong password" instead of taking the endpoint down. The bcrypt computation dominates the runtime (~100 ms either way), so timing tells an attacker nothing useful about the password itself.
Three login-flow details that interviews probe:
- One message for both branches. "Email not found" versus "Wrong password" is user enumeration: an attacker assembles a valid-address list from response differences before touching a single hash. Combine the branches as above, and consider a deliberate, identical delay on the failure path.
session_regenerate_id(true)right after success. The session fixation attack plants a session id before login and reads it after; regenerating on the privilege change makes the pre-login id worthless, whiletruedeletes the old session file so the planted id cannot be reused either. Keep$_SESSION(your CSRF token survives) — only the id rotates.- Upgrade on login, never re-hash offline.
password_needs_rehash($stored, PASSWORD_BCRYPT, ['cost' => 12])answers "does this stored value match the algorithm and options I use now?" — different algorithm, lower cost, changed Argon2 parameters all returntrue. Because the plaintext is available at exactly one moment — the successful login — that is the only moment a rehash can ever happen. Raising cost from 10 to 12 then migrates the whole user base one login at a time, with zero notices sent.
Migrating a legacy md5/sha1 column without forcing a reset:
function legacy_ok(string $password, string $stored): bool
{
return hash_equals(hash('md5', $password), $stored)
|| hash_equals(hash('sha1', $password), $stored);
}
$stored = $user['password_hash'];
$ok = password_get_info($stored)['algoName'] !== 'unknown'
? password_verify($password, $stored)
: legacy_ok($password, $stored);
if ($ok) {
$new = password_hash($password, PASSWORD_DEFAULT); // bcrypt from now on
$pdo->prepare('UPDATE users SET password_hash = :h WHERE id = :id')->execute([':h' => $new, ':id' => $user['id']]);
}
password_get_info() reports algoName: unknown for anything it does not recognize — that single check tells native-format hashes from hex digests, so one login path serves both populations. Two caveats: keep the legacy branch behind a deadline (every login weakens the argument for keeping it, and md5 stays crackable for dormant accounts — a forced reset for stragglers is the honest end state), and hash comparisons use hash_equals() even here, so the legacy path does not introduce a timing oracle of its own.
What never belongs anywhere near this flow: logging the submitted password, echoing it in an error, putting it in a URL or redirect, or storing it "just for now" in a session or cache. Validation of what arrives is the job of the previous topic, Validating and Sanitizing Input — by the time a password reaches this code, it is only ever handled by these functions.
72 Bytes, Cost Tuning, and Login Mistakes That Undo a Good Hash
A correct hash with a careless login around it still loses accounts. Four mistakes account for most of the damage.
1. Bcrypt truncates at 72 bytes — not characters. password_hash() passes the password through bcrypt's 72-byte limit, so every character past byte 72 is ignored:
$long1 = str_repeat('A', 70) . 'SECRET-ONE';
$long2 = str_repeat('A', 70) . 'SECRET-TWO';
password_verify($long1, password_hash($long2, PASSWORD_DEFAULT)); // true — same first 72 bytes
For 3-byte UTF-8 text (CJK, accented characters) 72 bytes is only 24 characters, so a passphrase policy that promises "unlimited length" quietly isn't. Pick a policy and enforce it explicitly — reject over 72 bytes (strlen($password) > 72) with a clear message, or pre-hash long inputs (password_hash(hash('sha256', $password, true), PASSWORD_BCRYPT), applied consistently at hash and verify time). What you must not do is truncate silently, because users then believe their tail characters count.
2. Cost is a measurement, not a superstition. Benchmark on hardware that looks like production:
foreach ([10, 11, 12, 13] as $cost) {
$t = microtime(true);
password_hash('correct-horse-battery-staple', PASSWORD_BCRYPT, ['cost' => $cost]);
printf("cost %2d → %5.0f ms\n", $cost, (microtime(true) - $t) * 1000);
}
Aim for ~100–250 ms per hash: fast enough that login feels instant, slow enough that offline guessing crawls. Two forces pull in opposite directions — an attacker can trigger unlimited logins and burn your CPU, so the cost cannot climb without bound (pair it with per-account and per-IP rate limits), and hardware improves, so password_needs_rehash() at the new cost is how you keep up. PHP's default is cost 10; raising it is a one-line change that migrates users at their next login.
3. Password policy theater beats no policy, badly. "Must contain an uppercase, a digit, and a symbol" reliably produces Summer2026! — a pattern on every attacker's list — while blocking correct horse battery staple. What actually works: a length floor (12+ characters), a check against known-breached passwords (the k-anonymity range API sends only a hash prefix, never the password), per-account rate limiting with progressive lockout, and a second factor for high-value accounts. Do not rotate passwords on a schedule without cause — every forced rotation trains users toward Password1! variants.
4. Skipping the session step. The hash protects storage; the session protects the live login. Rotating the session id at login (shown in the previous chapter) closes session fixation, and the cookie flags — HttpOnly, Secure, SameSite — are covered in Security Headers and Cookie Flags.
Also on the "looks safe, isn't" list: double-hashing (md5(md5($p))) compounds speed, not cost; salting twice adds nothing because password_hash() already salts; comparing with == reopens type juggling (0e… digests are numeric strings that compare equal — the reason hash_equals() exists); and password_hash() throws a ValueError on an unknown algorithm in PHP 8, so a typo'd constant is a 500 rather than a silent plaintext write.
The summary an interviewer will accept in one breath: password_hash(PASSWORD_DEFAULT) stores a salted, slow KDF; password_verify() checks without reversing; password_needs_rehash() upgrades the cost on the next login — and nothing else ever sees the plaintext.
Quiz
1. Why does password_hash($p, PASSWORD_DEFAULT) not need you to generate or store a salt yourself?
2. You raise bcrypt cost from 10 to 12. How do existing users' stored hashes become cost-12 hashes?
3. A legacy column stores md5 hex digests. What is the correct in-place migration path?
4. A user's 100-character passphrase is accepted, but accounts with different tails beyond 72 characters keep verifying against each other's hashes. What is happening?
Flashcards
Question
What does password_hash produce, and what are the four parts of the result?
Click to reveal answer
Answer
A slow, salted one-way password hash: algorithm id (e.g. $2y$ bcrypt), cost (2^cost rounds, default 10), a 16-byte random salt generated for this password, and the digest — all embedded in one string, so verification needs nothing but the stored value.
Question
What do password_verify and password_needs_rehash each answer?
Click to reveal answer
Answer
password_verify: 'does this plaintext match the stored hash?' — it reparses algo/cost/salt, recomputes, compares, returns bool, and never reconstructs the password (malformed hash → false, not an exception). password_needs_rehash: 'does this stored value match the algorithm and options I use now?' — the trigger for upgrading cost or algorithm on the next login.
Question
Why is PASSWORD_DEFAULT safer than pinning an algorithm, and what does it cost you?
Click to reveal answer
Answer
It always names PHP's current secure default (bcrypt today, possibly Argon2id later), so new hashes track improvements without code changes — the cost is column sizing: keep VARCHAR(255) for hashes you have not met yet, and use password_needs_rehash to migrate old ones as users log in.
Question
What is bcrypt's 72-byte limit and how do you handle it correctly?
Click to reveal answer
Answer
Blowfish keys cap at 72 bytes, so characters beyond that are ignored — two passwords sharing the first 72 bytes verify as equal (72 bytes ≈ 24 CJK characters). Handle it with an explicit length policy (reject > 72 bytes) or consistent pre-hashing (hash('sha256', $p, true) before bcrypt); silent truncation makes users believe their tail counts.
Question
Which login-flow details matter besides hashing?
Click to reveal answer
Answer
One generic failure message for unknown-email and wrong-password (kills user enumeration), rate limiting per account/IP (a high cost is otherwise a free CPU-burn lever), and session_regenerate_id(true) immediately on success (defeats session fixation while $_SESSION — including the CSRF token — survives).
Revision Notes
Key Takeaways
- 1. password_hash() bundles what hand-rolled storage always misses: a unique per-password salt embedded in the output and a tunable work factor that taxes every guess
- 2. password_verify parses and recomputes from the stored string — it never reverses, never returns plaintext, and treats a corrupt hash as a plain failure
- 3. password_needs_rehash is the upgrade mechanism: raise cost or switch algorithm and the base migrates one successful login at a time
- 4. Legacy md5/sha1 columns migrate in place via password_get_info detection plus rehash-on-login, behind a deadline that ends in forced reset
- 5. The hash is only as strong as the login around it: 72-byte policy, measured cost, rate limits, generic messages, and session_regenerate_id
Interview Tips
- • "How do you store passwords in PHP?" — password_hash(PASSWORD_DEFAULT), password_verify on login, password_needs_rehash for upgrades; say explicitly that it is a slow salted KDF and that you never see the plaintext again
- • "Why not SHA-256?" — speed is the vulnerability: GPUs do billions of guesses per second offline, and a salt only removes precomputation; bcrypt's cost makes each guess a ~100 ms event
- • "How would you raise the cost factor in production?" — change the options, let needs_rehash rehash at each next login, monitor login latency, and rate-limit so the extra work cannot be weaponized
- • "What breaks when users pick very long passwords?" — bcrypt's 72-byte truncation; show the policy answer (reject or pre-hash consistently) and you have named a limit most candidates never heard of
Cheat Sheet
Hashing Passwords Correctly
The whole API:
$hash = password_hash($password, PASSWORD_DEFAULT); // salted, slow, unique per password
password_verify($password, $stored) // true/false — never reverses
password_needs_rehash($stored, PASSWORD_BCRYPT, ['cost' => 12]) // algo/options drift → true
password_get_info($stored)['algoName'] !== 'unknown' // native hash vs legacy md5/sha1 hex
Anatomy of the stored string: $2y$10$<22-char salt><31-char digest> — bcrypt · cost 2^10 rounds · per-password salt · digest.
Why it holds:
| Lever | Effect |
|---|---|
| unique salt (auto, embedded) | one rainbow table = one account |
| cost (default 10, +1 doubles time) | ~60–150 ms/guess → GPU throughput collapses |
PASSWORD_DEFAULT |
current default algorithm, may change → VARCHAR(255) column |
PASSWORD_ARGON2ID (7.3+) |
memory-hard: memory_cost 65536 KB, time_cost, threads |
Never: md5()/sha1()/sha256() (billions/sec), salt-and-concatenate (still fast), reversible encrypt/decrypt, FILTER-style "temporary" plaintext, homemade constructions.
Login flow:
if (!$user || !password_verify($password, $user['password_hash'])) {
http_response_code(401); exit('Invalid email or password'); // ONE message: no user enumeration
}
if (password_needs_rehash($user['password_hash'], PASSWORD_BCRYPT, ['cost' => 12])) {
$pdo->prepare('UPDATE users SET password_hash = :h WHERE id = :id')
->execute([':h' => password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]), ':id' => $user['id']]);
}
session_regenerate_id(true); // kills planted session id; $_SESSION (CSRF token) survives
Cost/algorithm upgrades migrate one login at a time — the plaintext exists at login, nowhere else.
Legacy md5/sha1 migration: detect with password_get_info()['algoName'] === 'unknown', verify with hash_equals(hash('md5', $p), $stored), then immediately password_hash + UPDATE. Put a deadline on the legacy branch; dormant md5 accounts end in a forced reset.
Traps:
- bcrypt truncates at 72 bytes (≈24 CJK chars): reject over 72 bytes or pre-hash
hash('sha256', $p, true)consistently — never truncate silently. - Benchmark cost on production-like hardware; target 100–250 ms; rate-limit logins so higher cost is not a free DoS knob.
- Policy beats theater: length floor 12+, breached-password check (k-anonymity range API), progressive lockout, 2FA — not
Summer2026!rules. ==comparison →0e…juggling; usehash_equals/password_verify. Unknown algorithm →ValueError(PHP 8), not a silent fallback.- Never log, echo, URL-embed, or session-store the submitted password.
OWASP framing: password storage with a slow salted KDF (bcrypt/Argon2id), verify in constant time, upgrade parameters on login.