Lesson 2 of 9 · 25m
Password Hashing Done Right: Argon2id, bcrypt, and the Cost Dial
Why fast hashes fail for passwords, how to tune Argon2id, and the storage format that makes verification safe.
Passwords live in a database that will someday end up on the internet — through a backup, a scraper, a misconfigured bucket, or an insider. When that day comes, the only thing standing between the dump and every account is what you stored. SHA-256(password) is not a wall; it's a speed bump for a rig doing billions of guesses per second.
Why fast hashes fail
A password hash's job is to make guessing expensive. SHA-256 is designed to be cheap — GPUs run it at billions of hashes per second. The fix isn't more rounds of SHA-256 (attackers parallelize trivially); it's functions engineered to burn CPU, memory, or both on every attempt.
The real options (as of 2026)
- Argon2id — the current recommendation (RFC 9106, 2021). Memory-hard: the defender picks a memory cost an attacker can't avoid because GPUs hate random memory access. Tune parameters to take ~250-500ms on your hardware: start with
m=65536(64 MiB),t=3,p=1and adjust. - bcrypt — still acceptable, 25 years of battle-testing. Two sharp edges: passwords are silently truncated at 72 bytes (your
hunter2+ 200 emoji becomeshunter2), and it lacks the memory-hardness of Argon2id. - scrypt — memory-hard like Argon2 but older; reasonable, rarely the best pick now.
- PBKDF2 — the compliance answer (FIPS-approvable). Use it only when auditors demand it; it needs ~600k+ iterations of HMAC-SHA-256 to be respectable and still isn't memory-hard.
What storing looks like
Don't build a custom format. The PHC string convention stores algorithm + parameters + salt + hash in one field:
from argon2 import PasswordHasher
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=1)
stored = ph.hash("correct horse battery staple")
# -> $argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>
try:
ph.verify(stored, "correct horse battery staple") # constant-time compare inside
except Exception:
# identical error path for wrong password — never say which part failed
passThe salt is generated per-password, so identical passwords produce different digests — rainbow tables die here. If your parameters get stronger later, just re-hash on next successful login and store the new string; the parameters ride along in the field.
- Pepper — a server-side secret (in a KMS or secrets manager, never in the DB) mixed in before hashing. If the database leaks without the pepper, offline cracking stalls entirely. It's optional, but it's cheap insurance when you already have key management.
Try this
import time
ph = PasswordHasher(time_cost=1, memory_cost=1024)
t = time.time(); ph.hash("x" * 64); print("cheap:", time.time() - t)
ph = PasswordHasher(time_cost=3, memory_cost=262144)
t = time.time(); ph.hash("x" * 64); print("hard :", time.time() - t)Watch 256 MiB of memory-hard work cost you hundreds of milliseconds — then imagine an attacker renting GPUs to pay that per guess. Also: find a login path in your codebase, feed it a 10,000-byte password, and time it. Cheap hash functions have a DoS surface.
Pitfalls
- Cap input length (~1 KiB is generous) to block CPU/memory exhaustion attacks on your hash call.
- Rate-limit login endpoints independently of hash cost; the hash protects the dump, not the login form.
- Never store the password and a hint. Plaintext hints travel with the dump.
- bcrypt's 72-byte truncation is silent — if you're stuck on bcrypt, pre-hash with SHA-256 + base64 first.