Password Hashing with bcrypt and Argon2id, Measured
Security lecture 2: crack an unsalted hash with a 20-word list, time bcrypt, Argon2id, scrypt and PBKDF2 per guess, and hit bcrypt's 72-byte limit.
Series: Security Basics lecture 2/6 (beginner). For developers who are about to write their first sign-up form, or who inherited a users table and are not sure the passwords in it are stored safely. After this lecture you can store and check passwords correctly in PHP and Python, say how slow "slow enough" is in milliseconds, and avoid the mistakes that keep showing up in breach reports.
Lecture 1 ended with a number: one laptop core computes about 7.89 million SHA-256 hashes per second. This lecture shows what that means when your database leaks. Password storage sits under the OWASP Top 10 categories Cryptographic Failures and Identification and Authentication Failures (A02 and A07 in the 2021 edition). The fix is short to write; the details are where projects go wrong.
All runs: Apple Silicon Mac (M1 Pro), PHP 8.5.6 (bcrypt and Argon2id via password_hash, libsodium 1.0.22), Python 3.9.6, OpenSSL 4.1.0. Every "attack" here runs against users and a 20-word list we invented on our own machine.
Five ways to store a password, from worst to right
| Stored value | Example | What a database thief gets |
|---|---|---|
| Plain text | sunshine1 |
Every password, instantly, including for other sites where users reused it |
| Encrypted | AES of the password | Every password, as soon as the key leaks too (and it usually lives on the same server) |
| Fast hash, no salt | sha256("sunshine1") |
Weak passwords in microseconds; identical passwords visible as identical hashes |
| Fast hash + salt | sha256(salt + pw) |
Weak passwords still in microseconds, one user at a time |
| Slow password hash | bcrypt, Argon2id, scrypt, PBKDF2 | Each guess costs tens to hundreds of milliseconds; strong passwords survive |
Two terms first. A salt is a random value, different for every user, that is hashed together with the password and stored next to it. It is not secret. Its job is to make two users with the same password get different hashes, so an attacker must attack each user separately. A slow password hash (also called a key derivation function, KDF) repeats its work many times, or needs a lot of memory, on purpose, and has a cost setting you raise as hardware gets faster.
Your first password_hash in 5 minutes
PHP has had the right tool built in since 5.5: password_hash() creates the salt, runs bcrypt (or Argon2id) and returns one string you store in one column.
<?php
$pw = 'tiger2026';
$h1 = password_hash($pw, PASSWORD_DEFAULT);
$h2 = password_hash($pw, PASSWORD_DEFAULT);
echo $h1, "\n", $h2, "\n";
var_dump($h1 === $h2); // same password, different salt
var_dump(password_hash($pw, PASSWORD_DEFAULT) === $h1); // the beginner mistake
var_dump(password_verify($pw, $h1), password_verify('tiger2025', $h1));
print_r(password_get_info($h1));
$2y$12$ml76qZwQkp2tlUE0cq8SROecy9eBO0VCT.qA6bCwVIdxwMoL.RBMq
$2y$12$29U5HBehXh1Llpb/miK5W.itq90n0dJTPPg7FSeA77my08szzXrES
bool(false)
bool(false)
bool(true)
bool(false)
Array
(
[algo] => 2y
[algoName] => bcrypt
[options] => Array
(
[cost] => 12
)
)
Read the string: $2y$ is bcrypt, 12 is the cost (2¹² rounds), the next 22 characters are the salt and the rest is the hash. Everything needed to verify is inside, which is why the third line matters: you never hash the login attempt and compare strings. You call password_verify(), which reads the salt and cost from the stored value.
A local crack demo: unsalted SHA-256 vs a 20-word list
Here is the bad old way, attacked the way a real attacker would. The users and the list are invented; nothing leaves the machine.
# Local demo only: invented users, invented 20-word list.
import hashlib, time
users = {'alice': 'sunshine1', 'bob': 'dragon2026', 'carol': 'sunshine1', 'dave': 'V7#qLm2!pZ9x'}
words = ("password 123456 qwerty letmein dragon dragon2026 monkey football sunshine sunshine1 "
"iloveyou admin welcome login princess master hello freedom shadow summer2026").split()
leak = {u: hashlib.sha256(p.encode()).hexdigest() for u, p in users.items()} # bad old storage
for u, h in leak.items():
print(f"{u:7s} {h[:20]}...")
t = time.perf_counter_ns()
table = {hashlib.sha256(w.encode()).hexdigest(): w for w in words} # 20 hashes, once
for u, h in leak.items():
print(f"{u:7s} -> {table.get(h, '(not in list)')}")
print(f"{len(words)} words, {len(leak)} users, {(time.perf_counter_ns() - t)/1000:.0f} µs")
alice 284fff3bd254b48cca05...
bob b2a385f29915899dfc6d...
carol 284fff3bd254b48cca05...
dave 3655812bce4e006ad2a4...
alice -> sunshine1
bob -> dragon2026
carol -> sunshine1
dave -> (not in list)
20 words, 4 users, 16 µs
Two things to notice. Alice and Carol have the same hash, so the attacker knew they shared a password before cracking anything. And the list was hashed once and reused for every user. That is what a precomputed table does at a much larger scale.
Adding a random 16-byte salt per user hid the duplicate (all four hashes became different), but the attacker now just runs the list once per user: 46 SHA-256 calls, 29 µs, same three passwords found. A salt is necessary, but it is not enough.
Now the same users stored with password_hash() (bcrypt, cost 12) and attacked with password_verify() for each word:
alice $2y$12$JYI31LI5rKH2nGKaP4wWZeblQ4/ZbHE5uj6.ih9Brsmktf6tAmq2a
bob $2y$12$KCwYLedWjVc90blEehxePusxGHTOjgn/9HxVsIWai/gadTk86JGwS
carol $2y$12$vMtbOeXSCyd7arGEXLsFvOgHb97m7G76dj8Qqh6.9stePL749R3UK
dave $2y$12$EF1rmNZLOB8fIuM5S6R9VeEiBa1vPpXXq1Q4TwrgGeJEbT.UC660S
alice -> sunshine1
bob -> dragon2026
carol -> sunshine1
dave -> (not in list)
46 password_verify calls, 10.8 s
Be honest about what happened here: bcrypt did not save sunshine1. A password that sits in the first 20 entries of any list falls whatever the hash is. What bcrypt changed is the price: 10.8 seconds instead of 29 microseconds, about 370,000 times more. Against a real list of millions of entries, that factor is the difference between minutes and years. Dave's random 12-character password was not found by either attack.
Measured: the cost of one guess
Each algorithm was timed on one core. PHP rows are the median of 11 runs after one warm-up (101 runs for SHA-256). The scrypt row and one PBKDF2 row use openssl kdf, with the 6 ms process start-up subtracted.
The same PBKDF2 (600,000 iterations of HMAC-SHA256) took 794 ms in PHP's hash_pbkdf2(), 178 ms in Python's hashlib.pbkdf2_hmac() and 66 ms in OpenSSL. Your login pays the slow implementation's price; the attacker uses the fast one. So choose the iteration count from the fastest number, not from what your server feels.
Here is the same data as "time to try N guesses on one core":
| Storage (1 core, M1 Pro) | Guesses/s | 20-word list | 1 million list | all 8-letter lowercase (26⁸) |
|---|---|---|---|---|
| SHA-256 (openssl) | 7,890,000 | 2.5 µs | 0.13 s | 7.4 hours |
| PBKDF2-SHA256, 600k (openssl) | 15.2 | 1.3 s | 18.3 hours | 437 years |
| Argon2id, 64 MiB, t=4 | 6.6 | 3.0 s | 1.8 days | 1,005 years |
| bcrypt, cost 12 | 4.2 | 4.7 s | 2.7 days | 1,567 years |
| scrypt, N=2¹⁷, r=8 | 3.3 | 6.0 s | 3.5 days | 1,993 years |
Real attackers use GPUs and many machines, so divide every cell by a large factor (not measured here). The ratios between rows are what matter. Argon2id and scrypt add a second defence the table does not show: each guess needs its own 64 MiB or 128 MiB of memory, which is what makes GPUs much less effective against them.
Upgrading the cost at login: password_needs_rehash
PHP 8.4 raised the default bcrypt cost from 10 to 12, and you will want to raise yours again in a few years. You cannot rehash stored hashes offline (you do not have the passwords), but you have the plain password for a moment at every successful login:
<?php
$users = ['alice' => password_hash('sunshine-river-42', PASSWORD_BCRYPT, ['cost' => 10])];
const DUMMY = '$2y$12$ml76qZwQkp2tlUE0cq8SROecy9eBO0VCT.qA6bCwVIdxwMoL.RBMq';
function login(array &$users, string $name, string $pw): bool {
$hash = $users[$name] ?? null;
if ($hash === null) { // unknown user: spend the same time anyway
password_verify($pw, DUMMY);
return false;
}
if (!password_verify($pw, $hash)) return false;
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$users[$name] = password_hash($pw, PASSWORD_DEFAULT);
echo "rehashed: ", substr($hash, 0, 7), " -> ", substr($users[$name], 0, 7), "\n";
}
return true;
}
foreach ([['alice', 'wrong'], ['alice', 'sunshine-river-42'], ['alice', 'sunshine-river-42'],
['mallory', 'x']] as [$u, $p]) {
$t = hrtime(true);
$ok = login($users, $u, $p);
printf("%-8s %-18s %-5s %6.1f ms\n", $u, $p, $ok ? 'OK' : 'FAIL', (hrtime(true) - $t) / 1e6);
}
alice wrong FAIL 59.6 ms
rehashed: $2y$10$ -> $2y$12$
alice sunshine-river-42 OK 299.0 ms
alice sunshine-river-42 OK 240.6 ms
mallory x FAIL 242.2 ms
The timings show a subtle point. The dummy hash for unknown users has cost 12, but Alice's old hash had cost 10, so "wrong password" (59.6 ms) and "no such user" (242.2 ms) were distinguishable until her hash was upgraded. Keep the dummy at the same cost as your real hashes. Timing leaks get their own lecture (lecture 6).
The bcrypt 72-byte limit, demonstrated
bcrypt only reads the first 72 bytes of the password. PHP 8.5.6 still does this silently:
<?php
$a = 'correct horse battery staple, and then a few more words to be extra safe: 2026';
printf("%d chars, %d bytes\n", mb_strlen($a), strlen($a));
$h = password_hash($a . ' A', PASSWORD_BCRYPT);
var_dump(password_verify($a . ' B', $h)); // bcrypt: only the first 72 bytes count
var_dump(password_verify(substr($a, 0, 72), $h));
$h = password_hash($a . ' A', PASSWORD_ARGON2ID);
var_dump(password_verify($a . ' B', $h)); // Argon2id: the whole input counts
78 chars, 78 bytes
bool(true)
bool(true)
bool(false)
A different ending was accepted, and so was the first 72 bytes alone. For English text 72 bytes is 72 characters, but in UTF-8 a Japanese or Korean character takes 3 bytes, so a passphrase in those languages hits the limit at 24 characters.
A related trap: bcrypt in PHP 8.5.6 refuses NUL bytes. The common fix "pre-hash with raw SHA-256" fails about 12% of the time, because 32 random bytes contain a \0 with probability 1 − (255/256)³² ≈ 11.8%:
first candidate with NUL in raw sha256: user-pass-2 (byte 12)
ValueError: Bcrypt password must not contain null character
Pepper: a secret outside the database
A pepper is a server-side secret mixed into every password before hashing. Unlike the salt, it is not stored in the database, but in an environment variable, a secret manager or a file the database server cannot read. If only the database leaks, the attacker cannot test a single guess. HMAC plus Base64 also solves both bcrypt traps above: the result is 44 ASCII characters, with no NUL and under 72 bytes.
<?php
$pepper = getenv('PW_PEPPER') ?: 'demo-only-pepper-change-me-32byt';
function prep(string $pw, string $pepper): string {
return base64_encode(hash_hmac('sha256', $pw, $pepper, true)); // 44 ASCII chars, no NUL
}
$a = 'correct horse battery staple, and then a few more words to be extra safe: 2026';
$h = password_hash(prep($a . ' A', $pepper), PASSWORD_DEFAULT);
var_dump(password_verify(prep($a . ' A', $pepper), $h));
var_dump(password_verify(prep($a . ' B', $pepper), $h)); // differs after byte 72: now rejected
var_dump(password_verify(prep($a . ' A', 'stolen-db-no-pepper'), $h));
bool(true)
bool(false)
bool(false)
The cost of a pepper is operational: lose it and nobody can log in, and changing it means waiting for every user to log in again. Many teams skip it and use Argon2id, which has no 72-byte limit. Both are reasonable; what is not reasonable is a pepper committed to the repository.
Python: no bcrypt in the standard library
Python 3.9's standard library has no bcrypt or Argon2. On this Mac it also has no scrypt, because its hashlib is built on LibreSSL 2.8.3:
AttributeError: module 'hashlib' has no attribute 'scrypt'
ModuleNotFoundError: No module named 'bcrypt'
hashlib.pbkdf2_hmac is always there, and with 600,000 iterations it matches OWASP's current guidance for PBKDF2-HMAC-SHA256. Store the algorithm, iteration count and salt with the hash, like PHP does:
import base64, hashlib, hmac, secrets
ITER = 600_000
def hash_password(pw: str) -> str:
salt = secrets.token_bytes(16)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, ITER)
b = lambda x: base64.b64encode(x).decode()
return f"pbkdf2_sha256${ITER}${b(salt)}${b(dk)}"
def verify_password(pw: str, stored: str) -> bool:
algo, it, salt, dk = stored.split("$")
test = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), base64.b64decode(salt), int(it))
return hmac.compare_digest(test, base64.b64decode(dk))
s = hash_password("correct-horse-2026")
print(s)
print(verify_password("correct-horse-2026", s), verify_password("correct-horse-2025", s))
pbkdf2_sha256$600000$y1hfmNLc3brnbX2ZQZK1Mw==$avNZ0CmpugFjjmXOMZ/4u2+Pbb03K56Twv4Zru4gESE=
True False
In a real project you would add a maintained argon2 or bcrypt package (not installed here). Frameworks such as Django already store passwords in this algorithm$iterations$salt$hash shape.
Common mistakes: what happens, and the fix
1. Hashing the login attempt and comparing strings. What happens: password_hash($pw) === $stored is always false, because each call uses a new salt (shown above). Someone then "fixes" it by removing the salt. The fix: password_verify().
2. Comparing hashes with == in PHP.
string(32) "0e462097431906509019562988736854"
string(32) "0e830400451993494058024219903391"
bool(true) // md5('240610708') == md5('QNKCDZO')
bool(false) // ===
bool(false) // hash_equals()
Both strings look like the number 0 × 10^…, so == compares them as numbers, even in PHP 8.5. The fix: password_verify() for passwords, hash_equals() for any other secret comparison.
3. A database column that is too short. What happens: bcrypt strings are 60 characters, Argon2id strings were 97 in our run, and PASSWORD_DEFAULT may change in future versions. A CHAR(60) silently truncates a future hash. The fix: VARCHAR(255), as the PHP manual recommends.
4. Trimming, lowercasing or limiting the password. What happens: trim() or strtolower() before hashing quietly makes passwords weaker, and a 16-character maximum blocks passphrases. The fix: take the bytes as typed, allow at least 64 characters (NIST SP 800-63B's guidance), and normalize Unicode (NFC) consistently if you normalize at all.
5. Different errors for "no such user" and "wrong password". What happens: attackers learn which e-mail addresses are registered. The fix: one message, and a dummy verify at the same cost, as in the login code above.
6. No limit on login attempts. What happens: a slow hash protects a leaked database, not your live login form. The fix: rate limits per account and per IP, plus breached-password checks at sign-up. And note that a slow hash costs your server CPU too: 237 ms per login means one core handles only about four logins per second.
Choosing algorithm and cost
| Situation | Choice | Starting parameters (check current OWASP guidance) |
|---|---|---|
| New PHP project | PASSWORD_ARGON2ID or PASSWORD_DEFAULT (bcrypt) |
Argon2id m=19 MiB, t=2, p=1 or more; bcrypt cost ≥ 10, PHP default 12 |
| Long passphrases, CJK text | Argon2id, or bcrypt with HMAC + Base64 pre-hash | as above |
| Python, standard library only | hashlib.pbkdf2_hmac |
SHA-256, 600,000 iterations, 16-byte salt |
| FIPS or compliance requirement | PBKDF2-HMAC-SHA256 | 600,000 iterations or more |
| Existing MD5/SHA-1 table | Wrap: password_hash(old_md5) now, replace at next login |
mark rows so you know which form they are in |
Then tune the cost on your production hardware: aim for something like 100 to 500 ms per hash, measured at peak load.
Honest limits and version notes
- Run on PHP 8.5.6, Python 3.9.6 (LibreSSL 2.8.3) and OpenSSL 4.1.0.
password_hash()arrived in PHP 5.5, Argon2i in 7.2 and Argon2id in 7.3. The default bcrypt cost moved from 10 to 12 in PHP 8.4; we checked 12 on 8.5.6. - The NUL-byte
ValueErrorand the silent 72-byte truncation were checked on PHP 8.5.6 only. Older PHP versions behave differently with NUL bytes (not run here). - All speeds are one M1 Pro core. Attackers' GPU numbers are much higher for fast hashes and much less dramatic for memory-hard ones; we did not run a GPU.
- OWASP's parameter recommendations change; the numbers in the table were current OWASP Password Storage Cheat Sheet values when written. Re-check them before you choose.
- Every attack used invented users and an invented 20-word list on a local machine. Real lists are millions of entries long.
Exercises
1. Your users table has 10,000 rows stored as sha256(password) with no salt. Without forcing a password reset, how do you protect them today?
2. Using the measured 236.7 ms per bcrypt-12 hash, about how many logins per second can one core handle, and what does that suggest for a login endpoint without rate limits?
3. A Korean user sets the passphrase 가나다라마바사아자차카타파하 repeated twice (28 Hangul characters). Is everything after some point ignored by bcrypt? Where?
Answers
1. Wrap the old hash: store password_hash(old_sha256_hex, PASSWORD_DEFAULT) and mark the row as "wrapped". At login, verify with password_verify(hash('sha256', $pw), $stored); on success, store a fresh password_hash($pw) and clear the mark. Every row is protected immediately, and rows become normal as users log in.
2. About 1000 / 236.7 ≈ 4.2 per second. An attacker can send far more requests than that and use up your CPU, so the login endpoint needs rate limits even though the hash is slow.
3. Yes. Each Hangul syllable is 3 bytes in UTF-8, so 72 bytes is 24 characters. The last 4 characters are ignored. Use Argon2id, or pre-hash with HMAC + Base64.
Next
- Previous: Security Basics 1/6: hashing vs encryption vs encoding
- Next: Security Basics 3/6: secure random tokens and secrets
- Later: JWT HS256 signing pitfalls, AES-GCM done right, timing attacks and constant-time compare
- Related: PHP security: XSS, CSRF and sessions, SQL injection with PDO and SQLite, auditing a legacy PHP app for SQLi and XSS
- All lecture series: the programming learning roadmap
FAQ
Is SHA-256 with a salt good enough for passwords?
No. A salt stops precomputed tables and makes identical passwords look different, but SHA-256 stays fast: PHP computed about 2.2 million salted SHA-256 hashes per second on one M1 Pro core. Our 20-word list still cracked every weak password in 29 microseconds. You need a deliberately slow hash such as bcrypt, Argon2id, scrypt or PBKDF2 with a high iteration count.
Does bcrypt really ignore everything after 72 bytes?
Yes. On PHP 8.5.6, we bcrypt-hashed a 78-byte passphrase plus ' A'; password_verify() also accepted the same passphrase plus ' B', and its first 72 bytes alone. A Japanese or Korean character is 3 bytes in UTF-8, so the limit is only 24 such characters. Argon2id has no such limit; with bcrypt, pre-hash with HMAC and Base64.
How do I move old users to a stronger cost or to Argon2id?
At login, after password_verify() succeeds, call password_needs_rehash() with your current algorithm and options. If it returns true, hash the plain password you just verified again and update the row. In our run a cost-10 hash became cost 12 on the first successful login. Users who never log in keep the old hash.