The goal of password storage is narrow and measurable: if your database is copied at 3 a.m. and nobody notices for a week, that week of offline compute must not be enough to turn most of your stored passwords into plaintext. Everything else — salting, parameters, rehashing — exists to serve that number. The stored artifact should be a verifier: something that proves a guess was right without letting you recover the guess.
Why not encryption?
Encryption is reversible by design: someone with the key decrypts every row. That key lives in config, in a secret manager, or in the same deployment — so your "protection" is a lookup away from plaintext, and anyone who can read the key gets every password, including the ones users reused on other sites. A hash has no such key: recovering a password means guessing it and re-running the function. Encryption is the right tool for data you must use (card numbers, PII); passwords are data you must only compare against.
What a password hash must be
A general-purpose hash (SHA-256, MD5, bcrypt's distant ancestors) is optimized to be fast — billions of evaluations per second on one GPU — which is precisely the wrong property here. Password hashing must be deliberately expensive along two axes:
- Slow — enough CPU work per guess that online and offline guessing rates collapse.
- Memory-hard — enough RAM per guess that a GPU or ASIC cannot pack millions of instances side by side and win on parallelism.
Argon2id is the current standard answer (it won the Password Hashing Competition), with scrypt and bcrypt as accepted alternatives. The parameters are the security, not the algorithm name — OWASP's baseline for Argon2id is 19 MiB of memory, 2 iterations, 1 parallel lane, and more memory is the first knob to turn as hardware improves. A typical verification lands at 50–100 ms on a modern core.
That cost is a capacity constraint you must budget for: at 100 ms per hash, 100 logins per second occupy ~10 CPU cores and 190 MiB of RAM (19 MiB × 10 in-flight hashes). If you deploy at 10 ms, you have quietly made guessing 10× cheaper for the attacker too.
Salts: unique per password, stored with the record
A salt is random data mixed into the hash so that identical passwords produce different verifiers:
import hashlib, hmac, os
def make_verifier(password: str) -> bytes:
salt = os.urandom(16) # 128 random bits, new for every password
key = hashlib.scrypt(password.encode(), salt=salt, n=2**15, r=8, p=1, dklen=32)
return salt + key # 48 bytes: 16 salt + 32 derived key
def verify(password: str, stored: bytes) -> bool:
salt, expected = stored[:16], stored[16:]
actual = hashlib.scrypt(password.encode(), salt=salt, n=2**15, r=8, p=1, dklen=32)
return hmac.compare_digest(expected, actual) # constant-time; no early returnWhat salts buy you, and what they do not:
- They defeat precomputed tables (rainbow tables) and make equal passwords unequal in storage, so one cracked hash reveals exactly one user's password — not everyone who chose "summer2026".
- They must be unique and generated from a CSPRNG; a per-site "salt" hardcoded in config is another constant.
- They are not secret. Store them alongside the verifier (the row format above), back them up, and never treat their exposure as a breach.
hmac.compare_digestmatters: an early-exit string comparison leaks prefix information over enough requests.
The arithmetic of guessing
This is the whole argument in numbers. A random 8-character lowercase password has 26^8 ≈ 2.1 × 10^11 candidates:
plain SHA-256, one modern GPU: ~10^9 - 10^10 guesses/second
-> 2.1 x 10^11 candidates: about 20 to 200 seconds
bcrypt (cost 12), one modern GPU: ~5 x 10^4 guesses/second
-> 2.1 x 10^11 candidates: about 4.2 x 10^6 seconds ≈ 48 daysTwo observations follow. First, the algorithm choice is worth five orders of magnitude —
and that gap widens with every GPU generation, which is why parameters get bumped over time.
Second, 48 days for a single password on a single GPU is not safety; it is why length
matters (12 random characters is 26^12 ≈ 9.5 × 10^16 candidates, 450,000× harder) and why
rate limits, breach checks, and MFA still have to exist. No storage scheme rescues the
password password123.
Upgrading without a forced reset
Parameters must be stored with the verifier so cost can increase over time:
users.password_row: [alg_id | params | salt | key] e.g. argon2id | m=19456,t=2,p=1 | ...At login, verify against the stored parameters; if they are below current policy, recompute with the new parameters and overwrite the row. The user's next legitimate login performs the upgrade for free — no email, no forced reset, no support load. Alongside this: check new passwords against breached-password lists (k-anonymity queries such as the HIBP range API avoid sending the full password), and keep login rate-limited per account and per IP.
Where this bites in production
- Fast hashes in the wild: any
md5(password),sha256(password), or framework default from a decade ago is effectively plaintext under GPU load. Inventory what you store. - Logging the password — in a login error, an analytics event, a support tool — bypasses all of this. Redact at the logging boundary, not in each call site.
- Verifying with a normal string comparison reintroduces a timing side channel after you did everything else right; use a constant-time compare.
- Peppers (a secret mixed in outside the database) defend against a database-only leak, but they bring back key management — treat them as a complement to salting, never a replacement for it.
- The end state worth planning for: passkeys/WebAuthn remove the stored secret entirely, leaving only a public key. Until then, the verifier is the target an attacker is paying for.
Further reading: the OWASP password storage cheat sheet.