backend / security / 04_password_hashing.md

Password hashing

4 min read source

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.

  1. On login, check the old hash format.
  2. If old and matches, rehash with argon2id and store. Mark account migrated.
  3. New signups always use argon2id.
  4. 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.

See 05_oauth2_oidc.md, 11_authentication/.