Identitypasswordshashingsaltbcryptargon2work factor

Password Storage

The database should never contain the password, and it should not contain a fast hash of the password either — because the entire threat model is what an attacker does with a copy of the table.

▶ Run the labFollow the failure

Frame the problem

Security starts with a concrete asset, attacker capability and trust crossing.

Asset
Every user's password — which, because of reuse, is also their password at their bank, their email provider and their employer.
Attacker & capability
Someone who already has a copy of your `users` table, obtained through SQL injection, a leaked backup, a compromised replica or a stolen laptop. The question is only what that copy is worth.
Trust boundary
The boundary between "the database was disclosed" and "user accounts elsewhere were compromised" — a boundary made entirely of the hash function you chose.
AssetThreatAttack SurfaceTrust BoundaryVulnerabilityExploit PathImpactMitigationDefense in DepthResidual Risk

Deriving the requirement

Start from the question: should the database store the user's password? No — because everyone with database access, every backup, every replica, every log of a query, and every future attacker would hold it. And because users reuse passwords, that disclosure damages services you have no relationship with.

So store something derived from it that lets you verify a login but not recover the original. A one-way function. Now the attacker with the table cannot read passwords directly — they must *guess*: pick a candidate, apply the same function, compare. Everything that follows is about making that guessing loop as expensive as possible.

A salt — a unique random value per user, stored alongside the hash — stops the attacker from attacking all users at once. Without it, one precomputed table of common passwords cracks every account in the database simultaneously, and identical passwords are visibly identical in the table. With it, each account must be attacked independently.

A slow, memory-hard function stops the attacker from guessing quickly. This is the part that matters most and the part most often wrong. A general-purpose hash like SHA-256 is designed to be fast — that is its purpose — and commodity hardware computes it billions of times per second. A dedicated password hashing function is deliberately expensive in both time and memory, tuned so that one verification costs your server a fraction of a second and the attacker's parallel hardware cannot amortise it.

What the database holds
Password (never stored)Unique random saltWork factor / memory costSlow, memory-hard KDFStored: algorithm + params + salt + hash
UserLLMAgentToolDataDecisionHumanGuardrail

What to actually use

Use a dedicated password-hashing function: Argon2id where available, scrypt, or bcrypt. All three are salted and deliberately slow; Argon2id and scrypt are additionally memory-hard, which is what resists attackers using GPUs and custom hardware where memory, not computation, is the scarce resource. All three store their parameters inside the encoded hash string, so verification does not need separate columns and parameters can be upgraded per user over time.

Do not use SHA-256, SHA-512, MD5 or SHA-1 as the password storage mechanism, with or without a salt. Salting a fast hash fixes the precomputation problem and leaves the speed problem entirely intact — and the speed problem is the one that decides whether a disclosed table is a crisis or an inconvenience. "SHA-256 with a salt" is a common interview answer and a clear signal that the candidate has learned the vocabulary without the threat model.

Tune the work factor to your hardware and re-tune it periodically: pick the highest cost that keeps login latency acceptable — a common target is 100–500 ms per verification on your production instance type — and remember that this cost is paid on every login, so it interacts with capacity planning and with login-flood denial of service. Raise it as hardware improves. Because the parameters live in the stored hash, you can upgrade transparently: on a successful login, if the stored parameters are below current policy, re-hash the password you just verified and write it back.

Fast hash — a disclosed table is a password list
1import hashlib
2
3def store(password: str, salt: bytes) -> str:
4 # Salted, and still wrong: SHA-256 is designed to be fast.
5 # Commodity hardware tries billions of candidates per second, per salt.
6 return hashlib.sha256(salt + password.encode()).hexdigest()
7
8def verify(password: str, salt: bytes, stored: str) -> bool:
9 return store(password, salt) == stored # also: non-constant-time comparison
Dedicated password hashing — guessing is the expensive part
1from argon2 import PasswordHasher
2from argon2.exceptions import VerifyMismatchError, InvalidHashError
3
4# Parameters are tuned so one verification costs ~250 ms on production hardware.
5ph = PasswordHasher(time_cost=3, memory_cost=64 * 1024, parallelism=4)
6
7def store(password: str) -> str:
8 # The returned string embeds algorithm, version, parameters and salt.
9 # $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
10 return ph.hash(password)
11
12def verify(password: str, stored: str) -> bool:
13 try:
14 ph.verify(stored, password) # constant-time internally
15 except (VerifyMismatchError, InvalidHashError):
16 return False
17 if ph.check_needs_rehash(stored): # parameters below current policy
18 upgrade_stored_hash(store(password)) # transparent upgrade on successful login
19 return True

Both versions refuse to store the password. Only the second makes the attacker's guessing loop expensive. With a fast hash, a disclosed table yields most passwords in hours; with a tuned memory-hard function, the same table yields only the weakest passwords, and only slowly.

Peppering, migration and the rest of the flow

A pepper is a secret value mixed into the hash that lives outside the database — in a secret manager, an environment variable, or ideally a hardware security module that performs a keyed operation without releasing the key. Its purpose is narrow but real: an attacker who obtains only the database, and not the application's secrets, cannot attack the hashes at all. It does not help against an attacker who has both, and it introduces an operational burden (rotating a pepper requires re-hashing on next login, holding both values during transition). Worth it for high-value systems; not a substitute for the work factor.

Migrating from a weak scheme is a common real task and does not require asking every user to reset. Wrap the old hash: store argon2(sha256_hash) and mark the row as wrapped. Verification for a wrapped row computes the legacy hash first, then verifies the wrapper. On a successful login you have the plaintext password and can replace the row with a clean argon2(password). Users migrate silently as they log in, and after a window you force a reset for the remainder.

Finally, the parts of the flow that are not the hash. Never log the password field, including in request-body capture, error reporting and APM traces. Never accept it in a URL or query string, where it lands in access logs and referrers. Check candidate passwords against a corpus of known-breached passwords at registration and change time — this prevents far more real compromise than composition rules do. And accept long passphrases: a maximum length that is too low (or a truncating hash, which bcrypt does at 72 bytes) silently weakens what users chose.

  • Argon2id, scrypt or bcrypt. Not SHA-256, not with a salt, not "but we salt it".
  • Unique random salt per user, generated by a CSPRNG, stored with the hash (all three do this for you).
  • Tune the work factor to ~100–500 ms per verification and re-tune it as hardware improves.
  • Re-hash on successful login when the stored parameters are below current policy.
  • Check against a breached-password corpus; drop composition rules; allow long passphrases.
  • Never log, never in a URL, never in an error report. Redact by field name at the logging layer.

Key points

  • The threat model is a disclosed table; every design decision follows from making offline guessing expensive.
  • A salt prevents attacking all users at once. A slow, memory-hard function prevents attacking one user quickly. You need both, and the second matters more.
  • SHA-256 with a salt is not password storage — its speed is precisely the property that makes it wrong.
  • Parameters live inside the encoded hash, so work factors can be upgraded transparently on login.
  • Breached-password checks prevent more real compromise than complexity rules; MFA prevents more than either.

Boundary control exercise

This lesson uses the shared boundary-control exercise.

Boundary control check
Untrusted input / identity
Trust boundary
Privileged asset
Prevention may fail silently.

Follow the attack

Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.

  1. 1
    Attacker → obtain the table: SQL injection, a leaked backup, an exposed replica, an over-privileged analytics role.
  2. 2
    Table → offline: no rate limits apply, no alerts fire, and time is unlimited.
  3. 3
    Offline → candidate list: start with breach corpora and common passwords, ordered by frequency.
  4. 4
    Guessing → matches: with a fast hash, most passwords fall quickly; with a tuned KDF, only the weakest do, and slowly.
  5. 5
    Matches → credential stuffing on other services, where the same password very often works.
Blast radius
  • Compromise of accounts on your service *and* on unrelated services where users reused the password.
  • Notification obligations and reputational damage that scale with how quickly the hashes fall.
  • A weak scheme converts one disclosure into an indefinite stream of account takeovers as the cracking continues.

Defend, detect, recover

One prevention is a single point of security failure. Layer it and make failure observable.

Prevent
  • • Use Argon2id (or scrypt/bcrypt) with tuned parameters; never a general-purpose hash.
  • • Add a pepper held outside the database for high-value systems.
  • • Reject breached passwords at registration and change time.
  • • Enforce MFA so a cracked password alone is insufficient.
  • • Restrict which identities can read the credential table at all — most services do not need it.
Detect
  • • Alert on any read of the credential table by an identity that is not the authentication service.
  • • Alert on bulk reads or full-table scans of `users`.
  • • Monitor for your users' credentials appearing in breach corpora, and force resets when they do.
Respond & recover
  • • Assume every hash is being cracked from the moment of disclosure; the work factor decides how much time you have.
  • • Force a global password reset, invalidate all sessions and tokens, and require MFA re-enrolment where appropriate.
  • • Notify users explicitly that the password should be changed *everywhere it was reused* — this is the actual harm.
Residual risk
  • • Weak passwords fall regardless of the algorithm; the KDF buys time, not immunity.
  • • The work factor is bounded by the login latency and capacity you can afford, which is a permanent ceiling.
  • • A pepper protects only against database-only disclosure, and application compromise is common.

Misconceptions

Claim
“We salt our SHA-256 hashes, so we are fine.”
Reality
Salting defeats precomputation and nothing else. The attacker still tries billions of candidates per second per account, which is enough to break most real passwords.
Claim
“We encrypt passwords.”
Reality
Encryption is reversible, so the system can recover the plaintext — and so can anyone who obtains the key, which lives near the data. Password storage must be one-way. Saying "encrypt the password" is a reliable interview red flag.
Claim
“Longer minimum length and symbols make passwords strong.”
Reality
Length helps; composition rules mostly produce `Password1!` and reuse. Breach-corpus checks plus MFA do the real work.