Password hashing, timing attacks and account enumeration
How to store a password, why a fast hash is the wrong hash, and the login response that quietly tells an attacker which emails are registered.
Passwords and login — hashing, timing attacks, and account enumeration
The sentence to own: you never store a password, and the hash you store must be deliberately slow — because the attacker who gets your database has offline, unlimited, parallel attempts, and the only defence left at that point is making each attempt expensive.
Day 015 gave you hashing versus encryption. Today is the login endpoint itself, where four separate things go wrong: the algorithm, the timing, the messages, and the rate limit.
Part 1 — Why a fast hash is the wrong tool
SHA-256 IS AN EXCELLENT HASH AND A TERRIBLE PASSWORD HASH, FOR EXACTLY THE
REASON IT IS EXCELLENT: IT IS FAST.
⇒ a modern GPU computes BILLIONS of SHA-256 hashes per second. An
8-character password from a normal keyboard is a few hours. A
password from ANY leaked wordlist is instant.
⇒ AND THE THREAT MODEL IS SPECIFIC: this matters when the attacker HAS
YOUR DATABASE. At that point they are offline — no rate limit, no
logging, no lockout, unlimited parallelism, and all the time they want.
The ONLY remaining defence is COST PER GUESS.
⇒ SO YOU WANT A DELIBERATELY SLOW, MEMORY-HARD FUNCTION:
argon2id the current recommendation — tunable time AND memory
scrypt memory-hard, in hashlib
bcrypt fine, everywhere, decades of use — ⚠️ see the 72-byte trap
PBKDF2 acceptable, FIPS-approved, ⚠️ NOT memory-hard ⇒ GPU-friendly
⇒ MEMORY-HARDNESS IS THE POINT OF THE MODERN ONES: a GPU has thousands
of cores and comparatively little memory per core, so requiring 64 MB
per hash destroys the parallelism advantage in a way that pure CPU
cost does not.
from argon2 import PasswordHasher # pip install argon2-cffi
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4) # 64 MB
hashed = ph.hash(password) # '$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>'
READ THAT OUTPUT — IT IS SELF-DESCRIBING, AND THAT IS DELIBERATE:
the algorithm, the version, EVERY PARAMETER, THE SALT and the hash, all in
one string.
⇒ SO THE SALT IS NOT A SECRET AND DOES NOT NEED A SEPARATE COLUMN. Its
job is to make every stored hash UNIQUE, which defeats rainbow tables
and means cracking one password tells you nothing about the others.
⇒ AND YOU CAN CHANGE THE PARAMETERS LATER, because each hash carries
its own. `ph.check_needs_rehash(hashed)` after a successful login
lets you upgrade the whole user base silently, one login at a time.
⇒ TUNE FOR ~100-250 ms ON YOUR PRODUCTION HARDWARE. ⚠️ Higher is not
automatically better: a 2-second hash is a self-inflicted DoS at
the login endpoint, because an attacker can make you do the work.
⚠ The bcrypt trap: it silently truncates at 72 bytes. So a 100-character passphrase is only as strong as its first 72 bytes, and — worse — a common workaround, pre-hashing with SHA-256 and base64-encoding, can produce NUL bytes that truncate it further. Use argon2id for new systems and you never meet this.
A pepper — a secret added to every password before hashing, stored in the application config rather than the database — defends the case where the attacker steals the database but not the application secrets. Real benefit, real operational cost (rotating it means rehashing everyone), and it is an ADR-worthy decision (Day 101).
Part 2 — Timing, and what it leaks
# ⚠️⚠️ THE VULNERABLE LOGIN — and the bug is not where people look:
def login(email, password):
user = db.get_user_by_email(email)
if user is None:
return None # ⚠️⚠️ RETURNS IN ~1 ms
if not ph.verify(user.hash, password):
return None # ⚠️ returns in ~150 ms
return user
THE TIMING ITSELF IS THE ORACLE: "no such user" answers in a millisecond;
"wrong password" takes 150 ms because it did the hash.
⇒ SO AN ATTACKER CAN ENUMERATE YOUR ENTIRE USER BASE WITHOUT EVER
GUESSING A PASSWORD — and knowing that alice@example.com has an
account here is itself valuable: for phishing, for credential
stuffing, and for anything embarrassing about the site's subject matter.
⇒ THE FIX: HASH A DUMMY ANYWAY, so both paths cost the same.
DUMMY_HASH = ph.hash("dummy-password-for-constant-time-login") # computed once at import
def login(email: str, password: str) -> User | None:
user = db.get_user_by_email(email)
try:
ph.verify(user.hash if user else DUMMY_HASH, password) # ALWAYS hash
except VerifyMismatchError:
return None
if user is None:
return None # same cost, same answer
if ph.check_needs_rehash(user.hash): # silent upgrade
db.update_password_hash(user.id, ph.hash(password))
return user
AND THE OTHER TIMING RULE, WHICH APPLIES TO EVERY SECRET COMPARISON:
`token == expected` COMPARES BYTE BY BYTE AND RETURNS EARLY on the first
mismatch ⇒ the time taken reveals how many leading bytes were right ⇒
an attacker recovers the secret one byte at a time.
⇒ USE `secrets.compare_digest(a, b)` FOR EVERY SECRET: tokens, API
keys, HMAC signatures (Day 116), CSRF tokens (Day 115).
⇒ password *hash* comparison is already handled by the library, but
anything you compare yourself is yours to get right.
Part 3 — Account enumeration, everywhere
THE TIMING FIX IS NOT ENOUGH — THE SAME LEAK APPEARS IN FOUR OTHER PLACES,
AND FIXING ONLY LOGIN IS THE COMMON HALF-MEASURE:
1. THE MESSAGE. "No account with that email" versus "Incorrect password"
⇒ use ONE message for both: "Invalid email or password."
2. REGISTRATION. "That email is already registered" ⇒ a perfect
enumeration oracle, and the hardest one to fix because the UX genuinely
wants to tell the user. The standard answer: always respond "check
your email", and send EITHER a verification link OR a "someone tried to
register with your address" note. The information moves to the
inbox, which only the real owner reads.
3. PASSWORD RESET. Same shape, same fix: always "if that address exists,
we have sent a link."
4. STATUS CODES AND RESPONSE SIZE. Identical messages are useless if
one path 404s and the other 401s, or if the bodies differ by 3 bytes.
⇒ ⚠️ AND THE HONEST TRADE-OFF, WHICH YOU SHOULD STATE RATHER THAN PRETEND
AWAY: this genuinely hurts usability — users cannot tell whether they
mistyped their address. For a bank or a health service, take the hit.
For an internal tool where everyone's email is in the company directory,
it is theatre. Decide deliberately; do not copy the rule blindly.
Part 4 — Rate limiting login, and the lockout dilemma
TWO DIFFERENT ATTACKS NEED TWO DIFFERENT LIMITS, AND ONE LIMIT CANNOT DO BOTH:
BRUTE FORCE many passwords against ONE account
⇒ limit PER ACCOUNT: 5 failures ⇒ exponential backoff
CREDENTIAL STUFFING ONE leaked password against MANY accounts
⇒ a per-account limit sees ONE failure per account and never fires.
⇒ limit PER IP, and per ASN or fingerprint if the source is distributed
⇒ CREDENTIAL STUFFING IS THE MORE COMMON ATTACK IN PRACTICE, and it is
exactly the one a naive per-account limit misses entirely.
⚠️⚠️ THE LOCKOUT DILEMMA — a genuinely hard trade-off worth being able to
argue: "lock the account after 5 failures" MEANS ANYONE CAN LOCK ANYONE
OUT BY TYPING A WRONG PASSWORD FIVE TIMES. You have converted a
guessing attack into a denial-of-service against your own users, and
against your support desk.
⇒ BETTER: EXPONENTIAL BACKOFF rather than lockout — 1s, 2s, 4s, 8s.
Brute force becomes infeasible; a legitimate user waits a few
seconds and gets in.
⇒ plus a CAPTCHA or a proof-of-work after N failures, and a
notification email on a lockout or an unusual login.
⇒ AND MFA IS THE ACTUAL ANSWER TO PASSWORD GUESSING. Everything on
this page raises the cost; a second factor changes the category.
Part 5 — Policy, and what to log
NIST 800-63B REVERSED THE ADVICE EVERYONE STILL FOLLOWS. THE CURRENT
GUIDANCE, WITH THE REASONING:
✔ LENGTH OVER COMPLEXITY. Minimum 8, allow at least 64. Entropy
comes from length far more cheaply than from character classes.
✔ ALLOW EVERYTHING — spaces, unicode, emoji, the full character set.
Banning characters shrinks the space and blocks password managers.
✔ CHECK AGAINST BREACHED PASSWORD LISTS. This is the single highest-
value rule on the page, because it blocks the passwords actually being
tried. Have I Been Pwned's range API does it WITHOUT sending the
password: you send the first 5 hex characters of the SHA-1 and get back
every suffix in that bucket — k-anonymity, and you compare locally.
✘ NO COMPOSITION RULES ("one upper, one digit, one symbol") ⇒ they
produce `Password1!` and nothing else.
✘ NO FORCED PERIODIC ROTATION ⇒ it produces `Summer2026!` becoming
`Autumn2026!`. Rotate on evidence of compromise, not on a calendar.
✘ NO PASSWORD HINTS, no knowledge-based "security questions" ( your
mother's maiden name is on the internet).
WHAT TO LOG, AND WHAT MUST NEVER BE LOGGED (Day 099A):
✔ login success/failure, with user id, IP, user agent, timestamp
✔ password change, email change, MFA change ⇒ and EMAIL THE USER,
because the notification is what makes an account takeover detectable
✔ lockouts, rate-limit trips
✘ THE PASSWORD. Ever. Including inside a request body you logged
wholesale, which is how it always actually happens.
✘ the session token or the reset token — those are working credentials
⇒ redaction filters are a net, not the control: log fields you chose.
Common mistakes
| Mistake | Correction |
|---|---|
| SHA-256 for passwords | Fast is the flaw. Billions/sec on a GPU. Use argon2id. |
| A single global salt, or none | Per-password, stored in the hash string. |
| Rolling your own KDF | Use the library. This is not the place to be creative. |
| Returning early when the user does not exist | A timing oracle. Hash a dummy anyway. |
token == expected |
Early-return leaks the prefix. secrets.compare_digest. |
| Different messages for user/password | One message — and identical status and body size. |
| "That email is already registered" | A perfect enumeration oracle. Move it to the inbox. |
| Only per-account rate limiting | Misses credential stuffing entirely — the commoner attack. |
| Account lockout | Anyone can lock anyone out. Exponential backoff. |
| Composition rules | You get Password1!. Check breach lists instead. |
| Forced 90-day rotation | You get Summer2026! → Autumn2026!. |
| A 2-second hash | A self-inflicted DoS on your login endpoint. |
| Never rehashing | check_needs_rehash on login upgrades everyone silently. |
| Logging the request body on login | That is how passwords end up in logs. |
Interview questions
Q: How do you store passwords?
With a deliberately slow, memory-hard KDF — argon2id by preference, bcrypt or scrypt otherwise — tuned to roughly 100 to 250 milliseconds on production hardware. The reasoning is the threat model: this only matters once an attacker has the database, and at that point they're offline with unlimited parallel attempts, no rate limit and no logging. The only defence left is cost per guess. SHA-256 is a great hash and a terrible password hash for exactly the reason it's great — billions per second on a GPU. Memory-hardness matters because a GPU has thousands of cores but little memory each, so demanding 64 MB per hash destroys the parallelism advantage. The salt goes in the hash string, which is also what lets you raise the parameters later and upgrade users on login.
Q: What's wrong with returning early when the email doesn't exist?
It's a timing oracle. The no-such-user path returns in a millisecond and the wrong-password path takes 150 because it did the hash, so an attacker can enumerate your entire user base without ever guessing a password. The fix is to verify against a dummy hash when the user isn't found, so both paths cost the same. And that's only half of it — the messages, the status codes and the response sizes have to match too, and registration and password reset leak the same information more obviously. Registration is the hard one: the standard answer is to always say "check your email" and move the information into the inbox, which only the real owner reads.
Q: Should you lock an account after five failed attempts?
Generally no, because then anyone can lock anyone out by typing a wrong password five times — you've converted a guessing attack into a denial of service against your own users and your support desk. Exponential backoff is better: one second, two, four, eight. Brute force becomes infeasible and a legitimate user waits a few seconds. I'd also want two separate limits, because there are two attacks: per-account for brute force, and per-IP for credential stuffing, where one leaked password is tried against many accounts. A per-account limit sees a single failure per account and never fires, and stuffing is the more common attack in practice.
Q: What password policy would you set?
Length over complexity — minimum eight, allow at least sixty-four, accept every character including spaces and unicode, because banning characters shrinks the space and breaks password managers. No composition rules, because they produce
Password1!. No forced periodic rotation, because it producesSummer2026!becomingAutumn2026!— rotate on evidence of compromise instead. The highest-value rule is checking against breached password lists, since those are the passwords actually being tried, and Have I Been Pwned's range API does it without sending the password: you send the first five hex characters of the SHA-1 and compare the returned suffixes locally.
Q: Why not use == to compare a token?
Because
==on bytes returns as soon as it finds a mismatch, so the time taken reveals how many leading bytes were correct, and an attacker can recover the secret one byte at a time. It needs many samples and is noisy over a network, but it's real and the fix is free:secrets.compare_digest, which takes constant time relative to the input. I'd use it for every secret comparison — tokens, API keys, HMAC signatures, CSRF tokens.
Mini task
- Hash a password with argon2id and print the encoded string. Identify every field in it.
- Time
hashlib.sha256andph.hashon the same input. Write down the ratio. - Tune the argon2 parameters to ~150 ms on your machine. Then compute how many guesses per second that allows, and compare with SHA-256.
- Write the vulnerable login. Time 100 requests for an existing user and 100 for a non-existent one. Plot the two distributions.
- Fix it with the dummy hash and re-measure. Confirm the distributions overlap.
- Write a naive
==token check and time it against prefixes of increasing correctness. - Audit your own registration and password-reset endpoints for enumeration. Check messages, status codes and body lengths.
- Implement per-IP and per-account rate limiting with exponential backoff.
- Implement the HIBP k-anonymity check: SHA-1 the password, send the first 5 characters, compare suffixes locally. Confirm the password never leaves your process.
- Implement
check_needs_rehashand raise the cost parameters, then log in and confirm the stored hash upgrades. - Try a 100-character password with bcrypt, then change character 80. Confirm it still verifies. Explain.
Exit questions
Answer aloud, no notes.
- Why is a fast hash the wrong tool? State the threat model precisely.
- Why does memory-hardness matter specifically against GPUs?
- What is in an argon2 encoded hash, and what two things does that enable?
- What is a salt for? A pepper?
- Why is a 2-second hash a bad idea?
- Describe the timing oracle in the naive login, and the fix.
- Name the four other places account enumeration leaks.
- What is the honest trade-off of anti-enumeration?
- Why does per-account rate limiting miss credential stuffing?
- What is wrong with account lockout, and what replaces it?
- Give three things NIST now advises against, and what to do instead.
- Why
compare_digest?
Articulation drill
Record two minutes: "Walk me through implementing a login endpoint."
Start with storage and give the threat model, because the algorithm choice only makes sense from it: "passwords are stored with argon2id, tuned to about 150 milliseconds. The reasoning is that this only matters once someone has the database — at which point they're offline, with unlimited parallel guesses, no rate limiting and no logs. The only defence left is cost per guess, which is why SHA-256 is exactly wrong: billions per second on a GPU."
Then the timing point, which is the part that separates a real answer from a recited one: "the endpoint itself has to take the same time whether or not the account exists. The naive version returns in a millisecond for an unknown email and 150 for a wrong password, which lets someone enumerate the entire user base without guessing a single password. So I verify against a dummy hash when the user isn't found. And the same information leaks through the messages, the status codes and even the response body length — so one message for both, and registration says 'check your email' rather than 'that address is taken', moving the information into an inbox only the owner reads."
Then rate limiting, with the distinction people miss: "two limits, because there are two attacks. Per-account for brute force. Per-IP for credential stuffing, where one leaked password is tried against thousands of accounts — a per-account limit sees one failure each and never fires, and that's the commoner attack. And I'd use exponential backoff rather than lockout, because 'locked after five failures' means anyone can lock anyone out."
Close on the two things that matter more than any of it: "policy-wise, length over complexity and a check against breached password lists, which you can do without sending the password using k-anonymity. And honestly, MFA is the real answer — everything else raises the cost of guessing, and a second factor changes the category."
Previous: Day 113 · Tomorrow: Day 115 — sessions from scratch: cookie attributes, session fixation, and CSRF