Chapter 10 — Password storage is a completely different problem
In one sentence
Passwords need a deliberately slow hash with a unique salt, because attackers who steal your database will otherwise guess billions of candidates per second.
Why SHA-256 is the wrong tool here
Everything that makes SHA-256 a good hash makes it a terrible password hash: it's fast.
A modern GPU computes roughly 10 BILLION SHA-256 hashes per second.
A rig of 8 GPUs: ~100 billion per second.
Every 8-character lowercase password: 26^8 = 209 billion -> ~2 seconds
Every 8-character alphanumeric: 62^8 = 218 trillion -> ~36 minutes
Your users' passwords are not random. Attackers start with wordlists of the
billions of passwords already leaked, plus rules that append 123 and !. In
practice, most of a stolen SHA-256 password database is cracked within hours.
The three problems, and their fixes
Problem 1 — Speed. Fixed by a deliberately slow, tunable function.
Problem 2 — Identical passwords hash identically.
Without a salt:
alice sha256("password123") = ef92b778ba...
bob sha256("password123") = ef92b778ba... <-- same!
The attacker instantly sees they share a password, and cracking it once
cracks both. Worse, PRECOMPUTED TABLES (rainbow tables) of common
passwords let them skip the work entirely.
Fixed by a salt: random, unique per user, stored alongside the hash.
With a salt:
alice salt=7f3a... hash(salt + "password123") = 91cd44...
bob salt=c218... hash(salt + "password123") = 3e0ab7...
^^ different
Precomputation is now useless -- an attacker must attack each user
separately.
The salt is not secret. It's stored in plaintext with the hash. Its only job is uniqueness.
Problem 3 — GPUs are very good at hashing. Fixed by making the function memory-hard, so a GPU's thousands of small cores can't be used efficiently.
What to actually use (2026)
| Algorithm | Use it? | Recommended parameters* |
|---|---|---|
| Argon2id | First choice | ≥19 MiB memory, 2 iterations, 1 parallelism |
| scrypt | Good alternative | N=2^17 (128 MiB), r=8, p=1 |
| bcrypt | Fine; very widely available | work factor ≥10; beware the 72-byte input limit |
| PBKDF2-HMAC-SHA256 | Only when FIPS compliance requires it | ≥600,000 iterations |
| SHA-256, SHA-512, MD5 | Never for passwords | — |
* These are OWASP's current minimums. Verify against the OWASP Password Storage Cheat Sheet before you ship — the numbers rise as hardware improves.
How to tune: pick the highest cost your server can afford. A good target is ~250–500 ms per hash on your production hardware. Measure it; don't guess.
The bcrypt gotcha
bcrypt silently truncates input at 72 bytes. Two consequences:
1. A very long passphrase is cut short -- everything past 72 bytes is ignored.
2. If you PRE-HASH to work around it (bcrypt(sha256(password))), you must
base64-encode the digest first -- raw SHA-256 output can contain a NUL
byte, and some bcrypt implementations truncate at the first NUL,
catastrophically reducing the effective password.
If you need long passphrases, use Argon2id and avoid the whole issue.
Peppering (optional extra layer)
A pepper is a secret value added to every password hash, stored outside the database — in an HSM, a KMS, or at least a separate config store.
hash = Argon2id(password + pepper, salt)
If the attacker steals only the DATABASE, they can't crack anything
without the pepper.
If they steal the application server too, the pepper is gone.
It's genuinely useful defence in depth, but it complicates key rotation. Do the salt correctly first.
The complete implementation
# pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError
ph = PasswordHasher(
memory_cost=65536, # 64 MiB -- above the OWASP minimum; tune to your box
time_cost=3, # iterations
parallelism=4, # lanes
)
def register(password: str) -> str:
# argon2-cffi generates a random salt and stores it IN the hash string
return ph.hash(password) # store this whole string in the DB
def login(stored_hash: str, password: str) -> bool:
try:
ph.verify(stored_hash, password)
except (VerifyMismatchError, InvalidHashError):
return False
# transparently upgrade the cost when you raise parameters later
if ph.check_needs_rehash(stored_hash):
save_new_hash(ph.hash(password))
return True
The stored value looks like this — everything needed to verify is in it:
$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
\_______/ \__/ \______________/ \________/ \_________________________________/
algorithm ver parameters salt hash
That self-describing format is why you can raise the cost later without breaking existing users.
The other things that matter more than the hash
[ ] Rate-limit login attempts (per account AND per IP)
[ ] Check against known-breached passwords (Have I Been Pwned's k-anonymity
API lets you do this without sending the password or its full hash)
[ ] Support long passphrases -- set a maximum around 64-128 chars, not 16
[ ] Do NOT impose silly composition rules; NIST SP 800-63B advises against
forced complexity and forced periodic rotation
[ ] Offer MFA -- it defeats credential stuffing even when a password leaks
[ ] Never log the password. Check your error handlers and APM tooling.
[ ] Use a CONSTANT-TIME comparison for any token comparison (Chapter 11)
[ ] Return the SAME error and timing for "no such user" and "wrong password"
That last one prevents username enumeration — otherwise an attacker learns which accounts exist, which makes everything else easier.
Practice (35 min)
cd ~/sec-lab/scratch
pip install argon2-cffi bcrypt 2>/dev/null || pip3 install argon2-cffi bcrypt
python3 - <<'PY'
import time, hashlib
from argon2 import PasswordHasher
import bcrypt
pw = b"correct horse battery staple"
# 1. How fast is a plain hash? (This is the problem.)
t = time.time()
for _ in range(100000): hashlib.sha256(pw).hexdigest()
dt = time.time() - t
print(f"SHA-256: {100000/dt:>12,.0f} hashes/sec on ONE CPU core")
print(f" a GPU rig does this ~100,000x faster\n")
# 2. bcrypt
t = time.time(); h = bcrypt.hashpw(pw, bcrypt.gensalt(12)); dt = time.time()-t
print(f"bcrypt(12): {dt*1000:.0f} ms -> {1/dt:,.1f} hashes/sec")
print(f" stored: {h.decode()[:40]}...")
# 3. Argon2id
ph = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=4)
t = time.time(); h2 = ph.hash(pw); dt = time.time()-t
print(f"\nArgon2id: {dt*1000:.0f} ms -> {1/dt:,.1f} hashes/sec")
print(f" stored: {h2}")
# 4. Salts make identical passwords look different
print("\nSame password, three registrations:")
for _ in range(3): print(" ", ph.hash(b"password123")[:60], "...")
PY
Break it — build the attack, then defend against it:
python3 - <<'PY'
import hashlib, time
# A "database" hashed the naive way
db = {u: hashlib.sha256(p.encode()).hexdigest()
for u, p in [("alice","password123"),("bob","letmein"),
("carol","qwerty"),("dave","password123")]}
# Attacker observation #1: alice and dave have the SAME hash
print("identical hashes ->", [u for u,h in db.items() if h == db["alice"]])
# Attacker observation #2: a tiny wordlist cracks it instantly
wordlist = ["123456","password","letmein","qwerty","password123","admin"]
t = time.time()
rainbow = {hashlib.sha256(w.encode()).hexdigest(): w for w in wordlist}
for user, h in db.items():
print(f" {user:6} -> {rainbow.get(h, '(not in wordlist)')}")
print(f"cracked in {(time.time()-t)*1000:.2f} ms")
PY
Now re-run it with Argon2id and per-user salts, and observe that the same attack gains nothing.
Common confusions
- "We hash with SHA-512, that's stronger than SHA-256." Both are far too fast. Strength of the hash is not the problem; speed is.
- "We encrypt passwords." Then you can decrypt them, and so can whoever steals the key. Passwords should be hashed, never encrypted. (Exception: a password manager must be able to give the password back — that's a different problem.)
- "Our salt is
username + 'salt'." A salt must be random and unpredictable. A deterministic salt still allows targeted precomputation. - "We require 8 characters with a symbol and a number." Current NIST guidance
(SP 800-63B) says: allow long passphrases, screen against breached lists, and
stop forcing composition rules and periodic rotation. They push users toward
Password1!and reduce real entropy.
Check yourself
- Why is SHA-256 a bad choice for passwords?
- What is a salt, is it secret, and what attack does it prevent?
- What does "memory-hard" mean and why does it matter?
- Name the recommended password hash for new systems, with rough parameters.
- What is username enumeration and how do you prevent it?
Further reading
- Cheat sheet (read before shipping): OWASP Password Storage Cheat Sheet — the authoritative, regularly-updated parameter recommendations.
- Standard: NIST SP 800-63B, Digital Identity Guidelines: Authentication — the source of modern "don't force rotation" guidance. Section 5.1.1 is the one to read.
- Service: Have I Been Pwned's Pwned Passwords API — free, uses k-anonymity so you never send the full hash. Integrate it; it's a large win for little effort.
- Article: "How to Safely Store a Password" — Coda Hale. Old but the reasoning is timeless.