Frontend Labs
⋯

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

  1. Why is SHA-256 a bad choice for passwords?
  2. What is a salt, is it secret, and what attack does it prevent?
  3. What does "memory-hard" mean and why does it matter?
  4. Name the recommended password hash for new systems, with rough parameters.
  5. 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.

Generated from real-life-guide.md · 8 Oct 2026 16:36