Password hashing
You don’t store passwords. You store the output of a one-way function such that even with the database, an attacker has to do work proportional to the number of guesses.
Properties of a password hash
- Slow — fast hashes (MD5, SHA-256) compute billions/sec on a GPU. Password hashes deliberately tunable to cost ~0.1–1s per evaluation.
- Salted — per-user random value mixed into the hash so identical passwords produce different hashes (defeats rainbow tables and same-password leakage).
- Memory-hard (modern) — resists GPU/ASIC parallelism by requiring lots of RAM per evaluation.
What to use
| Hash | Status | Notes |
|---|---|---|
| argon2id | Recommended (current OWASP best) | Memory-hard; tunable time + memory + parallelism |
| scrypt | Acceptable | Memory-hard; older but solid |
| bcrypt | Acceptable | Battle-tested; not memory-hard but cost factor still works |
| PBKDF2 | Only if FIPS-required | Not memory-hard; weaker against modern hardware |
| SHA-256 / MD5 | Never | Fast = trivially crackable |
| Plain SHA + salt | Never | Same problem |
For new projects: argon2id. For existing bcrypt: don’t migrate just for this — bcrypt is still safe at appropriate cost.
With passlib (Python)
from passlib.hash import argon2
# Hash on signup
hashed = argon2.hash("user-password")
# stores: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
# Verify on login
argon2.verify("user-password", hashed) # True / False
Passlib bundles salt management, format, and verification. Don’t roll this yourself.
With argon2-cffi directly
from argon2 import PasswordHasher
ph = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=4)
hashed = ph.hash("user-password")
ph.verify(hashed, "user-password") # raises VerifyMismatchError on bad
ph.check_needs_rehash(hashed) # True if params have since increased
check_needs_rehash lets you progressively upgrade hashes during login: verify with the old params, rehash with new params, store.
Tuning the cost
The right cost is “the slowest you can tolerate during login.” For interactive logins: 100–500ms is a typical ceiling.
Calibrate on your production hardware:
import time
ph = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=4)
start = time.perf_counter()
ph.hash("benchmark")
elapsed = time.perf_counter() - start
print(f"{elapsed*1000:.0f}ms")
Bump cost periodically. Compute power doubles every ~2 years; hash cost should at least keep pace.
Salts
Salts must be:
- Per-user (not site-wide).
- Random (
secrets.token_bytes(16)or longer). - Stored alongside the hash (passlib/argon2-cffi format does this automatically).
The salt isn’t secret — it’s there to make every hash unique. An attacker who steals the DB has the salt, but still has to rerun the hash function for each user on each guess.
Pepper
A site-wide secret added to the password before hashing. Stored in env var / vault, not the DB.
import hmac, hashlib
def with_pepper(password: str, pepper: bytes) -> str:
return hmac.new(pepper, password.encode(), hashlib.sha256).hexdigest()
hashed = argon2.hash(with_pepper(password, PEPPER))
If the DB leaks but the pepper doesn’t, attacker can’t crack hashes — they’re missing the pepper.
Trade-off: rotating the pepper is hard (every hash needs re-derived on next login). Most teams skip pepper for the operational complexity.
Constant-time comparison
For any secret comparison — passwords, tokens, signatures:
import hmac
hmac.compare_digest(token_from_user, expected_token)
# never: token_from_user == expected_token
Regular == short-circuits at the first byte mismatch — leaks length and prefix via timing. compare_digest is constant-time relative to length.
(This applies less to argon2/bcrypt verify since the hash itself takes ~hundreds of ms; the variance dominates timing. But for raw token compare, always use constant-time.)
Migration from old hashes
You inherit a system using sha256(password + salt). You can’t rehash without users’ passwords (you only have the hashes). Strategy: dual-format.
- On login, check the old hash format.
- If old and matches, rehash with argon2id and store. Mark account migrated.
- New signups always use argon2id.
- Eventually all active users will have new hashes; force password reset on the long-tail.
def verify_login(stored_hash, password):
if stored_hash.startswith("$argon2"):
return argon2.verify(password, stored_hash)
elif stored_hash.startswith("sha256$"):
salt, expected = stored_hash.split("$", 2)[1:]
if hmac.compare_digest(sha256(salt + password), expected):
# upgrade in-place
new_hash = argon2.hash(password)
db.update(user_id, password_hash=new_hash)
return True
return False
What you don’t store
- The password.
- Reversibly-encrypted password (encryption keys leak).
- Hash without salt.
- Last N passwords for “no reuse” rules without salting each — same risks.
What you don’t do
- Force frequent rotation. NIST 800-63B advises against it (pushes users to weaker patterns like
Password2026!). Rotate on indication of compromise. - Cap password length. Or do, but generously (≥64 chars). Capping at 12 is a relic.
- Disallow special characters. The opposite of secure.
- Email the password. Even at signup. Send a reset link.
Interview angle
- Q: “How do you store passwords?” — argon2id (or bcrypt), per-user salt, never plain or fast hash.
- Q: “Why is bcrypt slow on purpose?” — cost factor; resists brute force.
- Follow-up: “What’s argon2id’s advantage over bcrypt?” — memory-hard; resists GPU/ASIC parallelism.
- Follow-up: “What’s a pepper, and is it worth it?” — site-wide secret outside DB; defense-in-depth, but rotation is operationally hard.