Password Hashing
Fast digests fail at this job by design. A single consumer GPU computes tens of billions of MD5 or SHA-1 guesses per second, and salting does not slow the guessing down — it only prevents precomputed tables and forces the attacker to crack each row separately. Wrapping a fast digest in a hand-rolled loop of 10,000 iterations does not rescue it either: the loop has no memory cost, no maintained security analysis, and a constant factor that hardware improvements erode every year. Password storage needs adaptive algorithms — bcrypt and Argon2 — whose work factor is a tunable parameter you raise as hardware gets faster. Argon2 additionally demands a configurable amount of RAM per hash, which blunts GPU and ASIC parallelism: memory is the one resource that stays expensive at scale.
This lesson owns how the password itself is stored and checked. The surrounding login flow — generic failure messages, credential stuffing, lockout policy — belongs to authentication and authorization security, and what happens after a successful login belongs to session security.
Hash And Verify
The entire correct usage is two functions. password_hash() hashes; password_verify() checks a submitted password against a stored hash.
<?php
declare(strict_types=1);
$first = password_hash('correct horse battery staple', PASSWORD_DEFAULT);
$second = password_hash('correct horse battery staple', PASSWORD_DEFAULT);
var_dump($first === $second);
var_dump(password_verify('correct horse battery staple', $first));
var_dump(password_verify('correct horse battery staple', $second));
var_dump(password_verify('correct horse battery stable', $first));
echo substr($first, 0, 7), ' ... ', strlen($first), " chars\n";
// Prints:
// bool(false)
// bool(true)
// bool(true)
// bool(false)
// $2y$12$ ... 60 chars
The same password hashed twice produces two different strings, and both verify. That is the salt at work: password_hash() generates a fresh random salt from the OS CSPRNG on every call and embeds it in the output. You never store a salt separately — a salt column in a schema is a legacy smell, not a requirement. The salt option itself was removed in PHP 8.0; passing it now emits a warning and is ignored.
Since PHP 8.0, password_hash() throws ValueError on bad arguments instead of returning false, so there is no failure return to check. password_verify() is the opposite: it never throws. Given an empty string, a truncated hash, or a value from some foreign format, it returns false — a corrupt row fails closed rather than taking the login path down.
The embedded salt is also why one infamous bug class exists: password_hash($input, PASSWORD_DEFAULT) === $row['hash'] is always false, even for the correct password, because the fresh call salts freshly. The API is verify-not-compare; more on this in the comparison museum below.
Anatomy Of A Stored Hash
The output is a self-describing string in crypt/PHC format: algorithm, parameters, salt, and digest, all in one column.
$2y$12$zkEEUsSdulJcFk5wViumkuYcYvYntGRm4uwxMsuX42WfdAqiQaSWu
└┬┘└┬┘└────────┬────────────┘└──────────────┬──────────────┘
algo cost 22-char salt 31-char digest
$argon2id$v=19$m=65536,t=4,p=1$Uvt4Pgoz2WB7uMweuWqOcg$lVfURqVoirtTba3JztjmqmAEpwbV8ozYC8eFnSlT3Oo
└───┬──┘ └┬─┘ └──────┬──────┘ └─────────┬──────────┘ └────────────────────┬────────────────────┘
algo version parameters base64 salt base64 digest
password_verify() reads everything it needs from the string itself, which is what makes it work across algorithms and parameter changes without any schema support. password_get_info() exposes the same parse:
<?php
declare(strict_types=1);
$bcrypt = '$2y$12$zkEEUsSdulJcFk5wViumkuYcYvYntGRm4uwxMsuX42WfdAqiQaSWu';
$argon2 = '$argon2id$v=19$m=65536,t=4,p=1$Uvt4Pgoz2WB7uMweuWqOcg$lVfURqVoirtTba3JztjmqmAEpwbV8ozYC8eFnSlT3Oo';
print_r(password_get_info($bcrypt));
print_r(password_get_info($argon2));
print_r(password_get_info('5f4dcc3b5aa765d61d8327deb882cf99'));
// Prints:
// Array
// (
// [algo] => 2y
// [algoName] => bcrypt
// [options] => Array
// (
// [cost] => 12
// )
//
// )
// Array
// (
// [algo] => argon2id
// [algoName] => argon2id
// [options] => Array
// (
// [memory_cost] => 65536
// [time_cost] => 4
// [threads] => 1
// )
//
// )
// Array
// (
// [algo] =>
// [algoName] => unknown
// [options] => Array
// (
// )
//
// )
That last result — algoName: "unknown" for a bare md5 hex digest — is the hook legacy migration hangs on later in this lesson.
Two storage rules follow from the format. First, size the column for the future, not for today: bcrypt output is 60 characters, this build's argon2id default output is 97, and the next PASSWORD_DEFAULT may be longer still — php.net recommends a 255-character column. A CHAR(60) column silently truncates the first Argon2id hash you store and locks that user out. Second, never normalise either side: no trim(), no lowercasing, not on the stored hash and not on the incoming password. "secret " and "secret" are different passwords, and a login form that trims while the registration form did not will lock users out in a way support can never reproduce.
Choosing The Algorithm
The algorithm constants have been strings since PHP 7.4, and password_algos() reports what the current build supports:
<?php
declare(strict_types=1);
print_r(password_algos());
var_dump(PASSWORD_DEFAULT === PASSWORD_BCRYPT);
$algo = in_array(PASSWORD_ARGON2ID, password_algos(), true)
? PASSWORD_ARGON2ID
: PASSWORD_DEFAULT;
echo "Chosen algorithm: {$algo}\n";
// Prints:
// Array
// (
// [0] => 2y
// [1] => argon2i
// [2] => argon2id
// )
// bool(true)
// Chosen algorithm: argon2id
The list is build-dependent: bcrypt ("2y") is always compiled in, but Argon2 requires PHP built with --with-password-argon2 or libsodium, so feature-detect rather than assume. On PHP 8.5, PASSWORD_DEFAULT and PASSWORD_BCRYPT are both "2y"; PASSWORD_ARGON2I is "argon2i" and PASSWORD_ARGON2ID is "argon2id".
PASSWORD_DEFAULT is a deliberate moving target — the manual says outright that it can change in future PHP releases. That is a feature, not a hazard: combined with password_needs_rehash(), it means your stored hashes track current recommendations without a migration project.
Which to choose: the OWASP Password Storage Cheat Sheet ranks Argon2id first because memory-hardness resists GPU farms, with bcrypt as the acceptable fallback. Argon2i alone (without the "d" component) is not recommended for password storage. PASSWORD_DEFAULT — bcrypt — remains a defensible production default precisely because it works on every build; a correctly tuned bcrypt beats a misconfigured Argon2id you had to fight your platform for.
Tuning bcrypt: The Cost Factor
bcrypt takes one option, cost, an exponent: each step doubles the work. Valid values are 4–31; anything else throws ValueError (Invalid bcrypt cost parameter specified). The default rose from 10 to 12 in PHP 8.4 — the first change since the password API shipped in PHP 5.5.
Do not copy a cost from a blog post; measure on the hardware that will run logins. php.net's guidance is to aim under ~350 ms for interactive logins. A calibration loop is a few lines:
<?php
declare(strict_types=1);
$budget = 0.250; // seconds per hash
$chosen = null;
foreach (range(10, 14) as $cost) {
$start = hrtime(true);
password_hash('timing-probe-only', PASSWORD_BCRYPT, ['cost' => $cost]);
$seconds = (hrtime(true) - $start) / 1e9;
printf("cost %d: %4.0f ms\n", $cost, $seconds * 1000);
if ($seconds <= $budget) {
$chosen = $cost;
}
}
echo "Highest cost within budget: {$chosen}\n";
// Prints (example):
// cost 10: 49 ms
// cost 11: 98 ms
// cost 12: 196 ms
// cost 13: 393 ms
// cost 14: 791 ms
// Highest cost within budget: 12
The numbers above are this machine's; yours will differ, which is the point. Pick the highest cost your latency budget tolerates, and re-measure when you change hardware.
Keep the threat models straight: cost protects the dumped table against offline cracking. It does nothing against an attacker hammering your live login endpoint — that is rate limiting, and neither control substitutes for the other. A cost-31 hash will not save users from credential stuffing, and a strict rate limit will not save a dumped table of md5 digests.
Tuning Argon2id: Memory, Time, Threads
Argon2id takes three options: memory_cost in KiB (PHP default 65536, i.e. 64 MiB), time_cost in iterations (default 4), and threads (default 1, and ignored when PHP uses libsodium's implementation). OWASP's minimum configurations are far below PHP's defaults — 19 MiB with time_cost 2 is the first one it lists — so the defaults are comfortably strong:
<?php
declare(strict_types=1);
$configs = [
'PHP defaults (64 MiB, t=4)' => ['memory_cost' => 65536, 'time_cost' => 4, 'threads' => 1],
'OWASP minimum (19 MiB, t=2)' => ['memory_cost' => 19456, 'time_cost' => 2, 'threads' => 1],
];
foreach ($configs as $label => $options) {
$start = hrtime(true);
password_hash('timing-probe-only', PASSWORD_ARGON2ID, $options);
printf("%s: %3.0f ms\n", $label, (hrtime(true) - $start) / 1e6);
}
// Prints (example):
// PHP defaults (64 MiB, t=4): 204 ms
// OWASP minimum (19 MiB, t=2): 34 ms
The trap is that memory_cost is charged per concurrent hash. Fifty simultaneous logins at the 64 MiB default is 3.2 GiB of real resident memory on your web tier, and a login spike becomes an OOM incident. Capacity-plan for peak concurrent logins × memory_cost, and if that number does not fit, lower memory_cost deliberately toward the OWASP minima — do not discover the limit in production.
bcrypt's 72-Byte Limit And NUL Bytes
bcrypt only reads the first 72 bytes of the password — bytes, not characters, so multibyte UTF-8 hits the wall sooner. Everything past byte 72 is silently ignored:
<?php
declare(strict_types=1);
$sharedPrefix = str_repeat('A', 72); // fills bcrypt's entire input window
$mine = $sharedPrefix . '-plus-my-secret-tail';
$theirs = $sharedPrefix . '-a-totally-different-tail';
$hash = password_hash($mine, PASSWORD_BCRYPT);
var_dump($mine === $theirs);
var_dump(password_verify($theirs, $hash));
$argonHash = password_hash($mine, PASSWORD_ARGON2ID);
var_dump(password_verify($theirs, $argonHash));
try {
password_hash("pass\0word", PASSWORD_BCRYPT);
} catch (ValueError $e) {
echo $e->getMessage(), "\n";
}
// Prints:
// bool(false)
// bool(true)
// bool(false)
// Bcrypt password must not contain null character
Two different passwords verify against each other's bcrypt hashes because they agree on the first 72 bytes; Argon2id has no such limit and correctly rejects. The ValueError on a NUL byte arrived as a security fix in the April 2024 releases (PHP 8.1.28, 8.2.18, and 8.3.5); on unpatched older builds, bcrypt silently truncated at the NUL — an even nastier hole.
That NUL rule is why the folk remedy for the 72-byte limit — pre-hash the password with hash('sha512', $pw, binary: true) before bcrypt — is dangerous. Raw binary digests routinely contain NUL bytes, which is now a hard error on bcrypt; and pre-hashing with a plain fast digest enables "password shucking", where an attacker who has cracked entries in a leaked fast-hash corpus can test them against your pre-hashed bcrypt without guessing the original password. The mitigations, in order of preference: use Argon2id (no length limit at all); or enforce a sane maximum password length at validation (reject over 64 bytes with a clear message — nobody's passphrase legitimately needs more); or, if pre-hashing is genuinely unavoidable, follow OWASP and use an HMAC encoded as base64 — a base64 SHA-256 HMAC is 44 bytes, under the cap and NUL-free.
Upgrading Hashes At Login
Costs get raised, defaults change, algorithms fall out of favour. password_needs_rehash() compares a stored hash's embedded algorithm and parameters against a target and returns true when they differ — cost-10 hash against a cost-12 target, bcrypt hash against an Argon2id target, or an empty/unrecognised string against anything.
There is exactly one moment when you can act on that answer: immediately after a successful password_verify(), because that is the only time the plaintext is legitimately in your hands.
<?php
declare(strict_types=1);
// One user row, as it might come back from PDO. The stored hash was made
// years ago at cost 10.
$row = [
'id' => 42,
'password_hash' => password_hash('hunter2!', PASSWORD_BCRYPT, ['cost' => 10]),
];
function login(array &$row, string $submitted): bool
{
if (!password_verify($submitted, $row['password_hash'])) {
return false;
}
// Only here is the plaintext legitimately in hand — upgrade now or never.
if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT)) {
$row['password_hash'] = password_hash($submitted, PASSWORD_DEFAULT);
// Real code: UPDATE users SET password_hash = :hash WHERE id = :id
echo "Hash upgraded for user {$row['id']}\n";
}
return true;
}
var_dump(password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT));
var_dump(login($row, 'hunter2!'));
var_dump(password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT));
var_dump(login($row, 'hunter2!'));
// Prints:
// bool(true)
// Hash upgraded for user 42
// bool(true)
// bool(false)
// bool(true)
The first login triggers the upgrade; the second finds nothing to do. This loop is how PASSWORD_DEFAULT changes and cost bumps roll out across a live user base with zero user-visible friction — active users upgrade themselves within days.
Migrating A Legacy Corpus
The harder version of the same problem: an app that predates the password API, with a table full of md5() hex digests. Three strategies, in the order you should reach for them.
Rehash at login. Detect the old scheme — password_get_info() reporting algoName: "unknown" is a clean signal, or sniff the format directly — verify with the old method one last time, and immediately store a real hash:
<?php
declare(strict_types=1);
// A 2009-era row: md5('trustno1') stored as hex.
$row = [
'id' => 7,
'password_hash' => '5fcfd41e547a12215b173ff47fdd3739',
];
function legacyAwareLogin(array &$row, string $submitted): bool
{
if (password_get_info($row['password_hash'])['algoName'] === 'unknown') {
// Legacy scheme. Compare in constant time — never with ==.
if (!hash_equals($row['password_hash'], md5($submitted))) {
return false;
}
$row['password_hash'] = password_hash($submitted, PASSWORD_DEFAULT);
echo "Migrated legacy row for user {$row['id']}\n";
return true;
}
return password_verify($submitted, $row['password_hash']);
}
var_dump(legacyAwareLogin($row, 'wrong-guess'));
var_dump(legacyAwareLogin($row, 'trustno1'));
echo substr($row['password_hash'], 0, 7), "\n";
var_dump(legacyAwareLogin($row, 'trustno1'));
// Prints:
// bool(false)
// Migrated legacy row for user 7
// bool(true)
// $2y$12$
// bool(true)
Its weakness: dormant accounts keep their crackable md5 digests indefinitely, and those rows are exactly what an attacker wants from a dump.
Wrap in place. OWASP's layered-hashing approach fixes the dormant-account gap: run password_hash($legacyMd5Hex, PASSWORD_DEFAULT) over the whole table in one migration, so no bare fast hash remains at rest. Verification becomes password_verify(md5($submitted), $stored) for wrapped rows — which means every row must carry or imply its scheme, typically a marker column or a prefix convention. At each user's next login, unwrap to a clean direct hash so the md5 layer eventually disappears.
Forced reset. For accounts that never return, expire the credential after a defined dormancy period and require a reset. It is the last resort because it burns user goodwill, but it is the only way to retire legacy rows that no login will ever touch.
Peppering
A pepper is a single site-wide secret folded into every password before hashing — defense-in-depth for the specific scenario where the attacker has your database but not your application servers. Apply it as an HMAC, never plain concatenation:
<?php
declare(strict_types=1);
// Real code reads this from the environment or a secret manager —
// never from the database the hashes live in, never from the repo.
$pepper = 'server-side-secret-from-env';
function pepperedHash(string $password, string $pepper): string
{
return password_hash(hash_hmac('sha256', $password, $pepper), PASSWORD_DEFAULT);
}
function pepperedVerify(string $password, string $hash, string $pepper): bool
{
return password_verify(hash_hmac('sha256', $password, $pepper), $hash);
}
$hash = pepperedHash('hunter2!', $pepper);
var_dump(pepperedVerify('hunter2!', $hash, $pepper));
var_dump(pepperedVerify('wrong', $hash, $pepper));
var_dump(pepperedVerify('hunter2!', $hash, 'a-rotated-pepper'));
echo strlen(hash_hmac('sha256', 'any password at all', $pepper)), " bytes into bcrypt\n";
// Prints:
// bool(true)
// bool(false)
// bool(false)
// 64 bytes into bcrypt
As a side effect, the hex HMAC is always 64 bytes — under bcrypt's 72-byte window, no NULs, so the pepper doubles as a length normaliser. The pepper must live outside the credential database — environment, secret manager — because the threat it counters is exactly "attacker has the database"; a pepper stored in the same database, or committed to the repo, defends against nothing. Storage mechanics are secrets management.
Be honest about the costs, which that third var_dump hints at: rotating the pepper invalidates every hash unless you re-wrap them or wait for each user's next login; the secret becomes an availability dependency (lose it and nobody can log in); and it is worthless against an attacker who also reached your filesystem or environment. A pepper is never a substitute for a slow hash — it is an optional layer on top of one.
The Comparison Museum
Three exhibits, two broken and one correct:
<?php
declare(strict_types=1);
$stored = password_hash('open sesame', PASSWORD_DEFAULT);
// Exhibit 1: hash-and-compare. Always false — every call salts freshly.
var_dump(password_hash('open sesame', PASSWORD_DEFAULT) === $stored);
// Exhibit 2: loose comparison of hex digests.
var_dump(md5('240610708') == md5('QNKCDZO'));
echo md5('240610708'), "\n";
echo md5('QNKCDZO'), "\n";
// Exhibit 3: the correct tools.
var_dump(password_verify('open sesame', $stored));
var_dump(hash_equals(md5('240610708'), md5('QNKCDZO')));
// Prints:
// bool(false)
// bool(true)
// 0e462097431906509019562988736854
// 0e830400451993494058024219903391
// bool(true)
// bool(false)
Exhibit 1 is the hash-and-compare bug: correct password, false every time, because the fresh call embeds a fresh salt. Exhibit 2 is worse — it returns true for the wrong password. Both digests start 0e followed by only digits, so == type-juggles them into floats: zero equals zero. These "magic hash" collisions are catalogued and actively hunted by attackers against any md5-plus-== login.
The rule generalises beyond passwords. Any secret you compare as a string — reset-token digests, API keys, HMAC signatures — needs hash_equals($known, $user), because ==, ===, and strcmp() all return early at the first differing byte, leaking match-length through response timing. password_verify() has constant-time comparison built in; hash_equals() is the same guarantee for everything else. Generating those tokens in the first place is cryptographic randomness; signing and verifying API tokens is JWT and API token security.
Hygiene At The Hashing Boundary
The plaintext password exists in memory for one request. Keep it that way: never write it to logs, and audit the paths that log for you — exception context arrays, request dumps in debug middleware, APM breadcrumbs that capture POST bodies. A hashing strategy is pointless if the plaintext sits in plaintext log storage with wider read access than the database.
One timing leak is specific to hashing. If your login skips the verify when the username does not exist, the fast "unknown user" response and the slow "wrong password" response are distinguishable, and the endpoint enumerates accounts by stopwatch. Burn the same work either way with a precomputed dummy hash:
<?php
declare(strict_types=1);
// Precomputed once at deploy time; not a real user's hash.
$dummyHash = '$2y$12$zkEEUsSdulJcFk5wViumkuYcYvYntGRm4uwxMsuX42WfdAqiQaSWu';
function checkCredentials(?array $row, string $submitted, string $dummyHash): bool
{
if ($row === null) {
// Unknown username: burn the same time a real check would cost,
// then fail. The response timing no longer says "no such account".
password_verify($submitted, $dummyHash);
return false;
}
return password_verify($submitted, $row['password_hash']);
}
var_dump(checkCredentials(null, 'anything', $dummyHash));
// Prints:
// bool(false)
The rest of the enumeration story — response wording, reset-flow behaviour — is lesson 08.
The Anti-Catalogue
Every entry below still ships in real codebases, each with the reason it fails:
- Plaintext or reversible encryption. The decryption key lives next to the data; a dump of one is a dump of both. Encryption answers "can I read this back?" — the one question password storage must answer no to.
md5()/sha1()/hash('sha256', ...), salted or not. Fast and GPU-parallel; salt only defeats precomputation, not guessing rate.- Hand-rolled iteration loops. No memory hardness, easy to get per-iteration salting wrong, and no security analysis behind your particular loop.
- Calling
crypt()directly. Easy to mis-salt and easy to misread its failure modes;password_hash()is the correctly-assembled wrapper. password_hash($input, ...) === $storedHash. Always false — a real bug class that reads plausibly in review and fails for every user at runtime.
If you want the libsodium-native alternative (sodium_crypto_pwhash_str()), it exists and is fine — see the OpenSSL and Sodium overview; for application code, the password_* API is the same strength with less ceremony.
What To Check
Before moving on, make sure you can:
- explain why bcrypt/Argon2id beat a salted SHA-256, in terms of an attacker's guesses per second;
- read the fields out of a
$2y$...or$argon2id$...string and say why no salt column exists; - calibrate bcrypt cost and Argon2id memory on real hardware instead of copying defaults;
- state bcrypt's 72-byte and NUL-byte behaviour and rank the mitigations;
- wire
password_needs_rehash()into the login path and migrate a legacy md5 table without a mass reset; - say where a pepper lives, what it defends against, and what it costs;
- pick
hash_equals()for every non-password secret comparison and explain the0emagic-hash bug.
Practice
Practice: Rescue A Legacy Login
// no-execute — the broken original, for reference only
function brokenLogin(array $row, string $submitted): bool
{
return md5($submitted) == $row['password_hash'];
}
It has two exploitable defects: the stored hashes are unsalted fast digests, and the == comparison type-juggles hex digests that start 0e into floats, so certain wrong passwords are accepted ("magic hashes").
Requirements
- Work against this in-memory fixture (no database needed):
// no-execute — fixture to paste into your script
$users = [
['id' => 1, 'name' => 'ravi', 'password_hash' => md5('correct horse battery staple')],
['id' => 2, 'name' => 'dana', 'password_hash' => md5('240610708')],
];
- First, demonstrate the magic-hash break: show that
brokenLogin()accepts the passwordQNKCDZOfor dana, whose real password is240610708. - Write a replacement
login(array &$row, string $submitted): boolthat:- detects whether a row still holds a legacy md5 digest (use
password_get_info()— do not guess by string length alone if you can avoid it); - verifies legacy rows in constant time (
hash_equals(), never==); - on a successful legacy login, immediately replaces the row's hash via
password_hash()withPASSWORD_DEFAULT; - verifies already-migrated rows with
password_verify()and upgrades them withpassword_needs_rehash()when parameters have moved on.
- detects whether a row still holds a legacy md5 digest (use
- Never store or log the plaintext anywhere except the transient function argument.
Acceptance Criteria
login()rejectsQNKCDZOfor dana — the magic-hash bug is gone.login()accepts each user's real password, and after that first success the row'spassword_hashstarts with$2y$(or$argon2id$if you target Argon2id).- A second login with the same password succeeds via the
password_verify()path, and a wrong password is rejected. - The script prints evidence for each point above (
var_dump()is fine).
Show solution
<?php
declare(strict_types=1);
// The user table after years of md5 storage. User 2's stored digest
// happens to start 0e — a "magic hash".
$users = [
['id' => 1, 'name' => 'ravi', 'password_hash' => md5('correct horse battery staple')],
['id' => 2, 'name' => 'dana', 'password_hash' => md5('240610708')],
];
// --- The old, broken login (never ship this) ---
function brokenLogin(array $row, string $submitted): bool
{
return md5($submitted) == $row['password_hash'];
}
// dana's stored digest is 0e46...; md5('QNKCDZO') is 0e83....
// PHP compares two "0e..." strings as numbers: 0 == 0.
var_dump(brokenLogin($users[1], 'QNKCDZO'));
// --- The rewrite ---
function login(array &$row, string $submitted): bool
{
$stored = $row['password_hash'];
if (password_get_info($stored)['algoName'] === 'unknown') {
// Legacy md5 row: constant-time compare, then upgrade immediately.
if (!hash_equals($stored, md5($submitted))) {
return false;
}
$row['password_hash'] = password_hash($submitted, PASSWORD_DEFAULT);
return true;
}
if (!password_verify($submitted, $stored)) {
return false;
}
if (password_needs_rehash($stored, PASSWORD_DEFAULT)) {
$row['password_hash'] = password_hash($submitted, PASSWORD_DEFAULT);
}
return true;
}
// The magic-hash attack is dead: hash_equals compares bytes, not floats.
var_dump(login($users[1], 'QNKCDZO'));
// Real passwords still work, and the first success upgrades the row.
var_dump(login($users[0], 'correct horse battery staple'));
echo substr($users[0]['password_hash'], 0, 7), "\n";
// Second login takes the password_verify() path.
var_dump(login($users[0], 'correct horse battery staple'));
var_dump(login($users[0], 'wrong password'));
// Prints:
// bool(true)
// bool(false)
// bool(true)
// $2y$12$
// bool(true)
// bool(false)
Why It Works
- The first
var_dumpproves the vulnerability is real:md5('QNKCDZO')is0e830400451993494058024219903391, dana's stored digest is0e462097431906509019562988736854, and==juggles both to float zero.hash_equals()compares raw bytes in constant time, so the same attack returnsfalse. - The
algoName === 'unknown'branch runs exactly once per user: after the first successful login the row holds a$2y$12$...hash (60 characters — remember the 255-character column), so every later login goes throughpassword_verify(). - The
password_needs_rehash()call in the modern branch means this function never needs touching again — a future cost bump or a change ofPASSWORD_DEFAULTrolls out through the same upgrade-at-login loop.
Verify It
Run the script: the six output lines must match the // Prints: block above. To go further, dump $users at the end and confirm no md5 hex digest survives for any user who logged in.
Practice: Cost Calibration Tool
Requirements
- Benchmark
password_hash()with bcrypt at costs 10 through 13 (at least), timing each withhrtime(true)and printing the result in milliseconds. - Benchmark Argon2id with at least three parameter sets, including the OWASP minimum (
memory_cost19456 KiB,time_cost2,threads1) and PHP's defaults (65536 KiB, 4, 1). - Feature-detect Argon2id with
password_algos()— the script must still produce a bcrypt recommendation on a build without Argon2 support, not crash. - Take a latency budget of ~250 ms per hash and print a recommendation: the highest bcrypt cost under budget, and the strongest Argon2id set under budget.
- Guard against a cold-start outlier (opcache warmup, CPU frequency scaling) — time more than one run per setting, or discard the first.
- Total runtime must stay under about 10 seconds, so pick iteration counts accordingly.
Acceptance Criteria
- Running the script prints one timing line per setting plus a final recommendation block naming a bcrypt cost and an Argon2id parameter set (or an explicit "not available" for Argon2id).
- Timings scale as expected: each bcrypt cost step roughly doubles the time.
- Re-running the script produces the same recommendation on the same idle machine.
Show solution
Each setting is timed twice and the faster run kept — the first password_hash() call in a process can pay one-off warmup costs, and keeping the minimum filters that noise without needing many iterations. The recommendation logic is just "last setting that fit the budget", relying on the fact that both lists are ordered weakest to strongest.
<?php
declare(strict_types=1);
const BUDGET_MS = 250;
function timeHash(string $algo, array $options): float
{
// Two runs, keep the faster: smooths out a cold first call.
$best = INF;
for ($i = 0; $i < 2; $i++) {
$start = hrtime(true);
password_hash('calibration-probe-only', $algo, $options);
$best = min($best, (hrtime(true) - $start) / 1e6);
}
return $best;
}
echo "bcrypt\n";
$bestCost = null;
foreach (range(10, 13) as $cost) {
$ms = timeHash(PASSWORD_BCRYPT, ['cost' => $cost]);
printf(" %-30s %6.0f ms\n", "cost {$cost}", $ms);
if ($ms <= BUDGET_MS) {
$bestCost = "cost {$cost}";
}
}
$bestArgon = null;
if (in_array(PASSWORD_ARGON2ID, password_algos(), true)) {
echo "argon2id\n";
$sets = [
'm=19 MiB, t=2, p=1' => ['memory_cost' => 19_456, 'time_cost' => 2, 'threads' => 1],
'm=64 MiB, t=4, p=1' => ['memory_cost' => 65_536, 'time_cost' => 4, 'threads' => 1],
'm=128 MiB, t=4, p=1' => ['memory_cost' => 131_072, 'time_cost' => 4, 'threads' => 1],
];
foreach ($sets as $label => $options) {
$ms = timeHash(PASSWORD_ARGON2ID, $options);
printf(" %-30s %6.0f ms\n", $label, $ms);
if ($ms <= BUDGET_MS) {
$bestArgon = $label;
}
}
}
echo "\nRecommendation for a ~", BUDGET_MS, " ms budget on this machine:\n";
echo " bcrypt: ", $bestCost ?? 'nothing under budget', "\n";
echo " argon2id: ", $bestArgon ?? 'not compiled into this build', "\n";
// Prints (example):
// bcrypt
// cost 10 49 ms
// cost 11 98 ms
// cost 12 196 ms
// cost 13 392 ms
// argon2id
// m=19 MiB, t=2, p=1 30 ms
// m=64 MiB, t=4, p=1 184 ms
// m=128 MiB, t=4, p=1 382 ms
//
// Recommendation for a ~250 ms budget on this machine:
// bcrypt: cost 12
// argon2id: m=64 MiB, t=4, p=1
Why It Works
hrtime(true)is a monotonic nanosecond clock, immune to wall-clock adjustments mid-benchmark; dividing by1e6gives milliseconds.- The bcrypt column doubles per step (49 → 98 → 196 → 392 ms here), which is the expected 2^cost behaviour and a quick sanity check that the machine was idle during the run.
- The Argon2id sets are ordered by strength, so the last one under budget is the strongest affordable choice. On this machine that is PHP's own default (64 MiB, t=4) — but the 128 MiB set failing the budget is exactly the kind of fact you only learn by measuring.
- The
password_algos()guard means a bcrypt-only build (Argon2 is a compile-time option) degrades to a bcrypt-only report instead of aValueErrorfrom an unknown algorithm.
Verify It
Run it twice on an idle machine: the recommendation lines must be identical between runs, and each bcrypt step should show roughly double the previous time. Numbers are hardware-dependent — that is the point of the tool. Remember the Argon2id caveat from the lesson before adopting the strongest set: memory_cost is charged per concurrent login, so check the recommendation against peak-login memory, not just latency.
Practice: Upgrade-At-Login Simulator
Requirements
- Seed an in-memory store (an array keyed by username) with three rows:
- one bcrypt hash created with
['cost' => 10](the pre-PHP-8.4 default); - one hash created with plain
PASSWORD_DEFAULT(already current); - one row still holding the plaintext password (the worst kind of legacy data).
- one bcrypt hash created with
- Implement
login(array &$store, string $user, string $submitted): boolso that:- plaintext rows are detected via
password_get_info()reportingalgoName: "unknown"and compared withhash_equals(), never==or===directly on user input; - real hashes are checked with
password_verify(); - after any successful authentication, the stored value is upgraded with
password_hash(..., PASSWORD_DEFAULT)wheneverpassword_needs_rehash()says so; - failed logins change nothing.
- plaintext rows are detected via
- Write a
report()helper that prints, for every row, whetherpassword_needs_rehash($stored, PASSWORD_DEFAULT)istrue.
Acceptance Criteria
- The report before any logins shows
needs_rehash: truefor the cost-10 and plaintext rows andfalsefor the default row. - After one successful login per user, the report shows
needs_rehash: falsefor all rows. - A failed login attempt before the upgrade leaves its row untouched (show it).
- Users can still log in after the upgrade, and wrong passwords still fail.
Show solution
<?php
declare(strict_types=1);
// Seed the store the way years of neglect would: an old cost-10 bcrypt
// row, a current-default row, and one row still holding the plaintext.
$store = [
'ravi' => ['scheme' => 'bcrypt-10', 'stored' => password_hash('north-sea-42', PASSWORD_BCRYPT, ['cost' => 10])],
'dana' => ['scheme' => 'default', 'stored' => password_hash('blue-kettle-9', PASSWORD_DEFAULT)],
'omar' => ['scheme' => 'plaintext', 'stored' => 'silver-spoon-7'],
];
function login(array &$store, string $user, string $submitted): bool
{
$stored = $store[$user]['stored'];
// Rows the password API does not recognise are legacy plaintext here.
if (password_get_info($stored)['algoName'] === 'unknown') {
if (!hash_equals($stored, $submitted)) {
return false;
}
} elseif (!password_verify($submitted, $stored)) {
return false;
}
// Authenticated: the plaintext is in hand, so upgrade whatever needs it.
if (password_needs_rehash($stored, PASSWORD_DEFAULT)) {
$store[$user]['stored'] = password_hash($submitted, PASSWORD_DEFAULT);
}
return true;
}
function report(array $store): void
{
foreach ($store as $user => $row) {
printf(
"%-5s %-10s needs_rehash: %s\n",
$user,
$row['scheme'],
password_needs_rehash($row['stored'], PASSWORD_DEFAULT) ? 'true' : 'false'
);
}
}
echo "Before any logins:\n";
report($store);
var_dump(login($store, 'ravi', 'wrong')); // failed logins change nothing
var_dump(login($store, 'ravi', 'north-sea-42'));
var_dump(login($store, 'dana', 'blue-kettle-9'));
var_dump(login($store, 'omar', 'silver-spoon-7'));
echo "After one successful login each:\n";
report($store);
// Everyone still logs in against the upgraded rows.
var_dump(login($store, 'omar', 'silver-spoon-7'));
var_dump(login($store, 'omar', 'wrong'));
// Prints:
// Before any logins:
// ravi bcrypt-10 needs_rehash: true
// dana default needs_rehash: false
// omar plaintext needs_rehash: true
// bool(false)
// bool(true)
// bool(true)
// bool(true)
// After one successful login each:
// ravi bcrypt-10 needs_rehash: false
// dana default needs_rehash: false
// omar plaintext needs_rehash: false
// bool(true)
// bool(false)
Why It Works
- Before any logins,
password_needs_rehash()flags exactly the two stale rows: the cost-10 hash (its embedded12target differs — cost is read straight out of the$2y$10$prefix) and the plaintext (unparseable, therefore alwaystrue). dana's default-cost row is already current, so her login verifies but skips the rewrite. - The failed
login($store, 'ravi', 'wrong')returnsfalsebefore reaching the upgrade block, so a wrong guess can never corrupt a stored credential. - omar's second login takes the
password_verify()branch — his row now holds a$2y$12$...hash — proving the upgrade is transparent: same password, new storage, no reset email. hash_equals()for the plaintext row is not optional hygiene:==against user input invites both timing measurement and type-juggling surprises, exactly the bug classes this lesson's comparison museum catalogues.
Verify It
Run the script and compare against the // Prints: block — every line is deterministic. Then extend the seed with a fourth row using ['cost' => 11] and confirm it, too, converges to needs_rehash: false after one login without touching login().