Appearance
8.4.1 — Passwords and Account Flows
A company hashes passwords with Argon2id, correct parameters, per-user salts. The storage is genuinely excellent.
Their password reset sends a token built from the user id and a timestamp, the reset page does not invalidate existing sessions, and "no account with that email" appears when the address is unknown.
An attacker never touches the password hashes. They enumerate valid accounts from the reset form, forge a reset token for one, take over the account, and the victim's other session stays live so nothing looks wrong.
Password security is not a hashing question. Hashing is the part everyone gets right; the account lifecycle around it is where accounts are actually lost.
1. Why a fast hash is the wrong tool
You never store a password. You store something derived from it that lets you check a guess and does not let you recover the original. That means hashing, not encryption — encryption implies a key, and a key implies someone who can decrypt everything.
But a general-purpose hash is the wrong hash. SHA-256 is designed to be fast; a modern GPU computes billions per second. Against a leaked table of SHA-256 password hashes, an attacker tests every common password against every user in minutes.
So password hashing deliberately inverts the goal: it must be slow, and expensively so.
Salt — a random value per user, stored alongside the hash in plain text. It solves two problems at once. Without it, two users with the same password have the same hash, so cracking one cracks both, and the pattern is visible in the dump. And it destroys rainbow tables — precomputed hash lookups — because the attacker would need a separate table per salt. A salt is not a secret; it is a uniqueness device. 16 random bytes from a CSPRNG (Chapter 8.2.1), never a user id or an email.
Pepper — a secret value, the same for all users, stored outside the database. In an environment variable, a key management service, or a hardware module. Its point is precise: if the attacker steals only the database, the hashes are uncrackable without the pepper. It buys nothing if they get the application server too. The practical way to add one is HMAC(pepper, password) before the KDF, which keeps it independent of the KDF and avoids bcrypt's length limit. Its real cost is rotation — changing it invalidates every hash unless you version it and re-hash on next login.
2. The algorithms, and the parameters that matter
Argon2id — the default choice. Winner of the 2015 Password Hashing Competition, and memory-hard: it requires a configured amount of RAM, which is what defeats GPUs and custom hardware. A GPU has thousands of cores and not thousands of independent memory pools. The id variant combines resistance to side channels and to GPU cracking, and it is the one to use.
Three parameters: memory (the important one), iterations, and parallelism. A commonly cited starting point is 19 MiB memory, 2 iterations, 1 degree of parallelism — and the correct method is to tune on your own hardware to a target verification time, typically 250–500 ms under expected load. Raise memory before iterations.
scrypt — also memory-hard, older, well tested. A fine alternative.
bcrypt — from 1999, still acceptable, with one trap that matters: it silently truncates input at 72 bytes. A long passphrase, or a pre-hashed password expressed as hex, can exceed that and reduce the effective secret. If you pre-hash before bcrypt, use base64 of raw bytes rather than hex, or use a different KDF. Use the $2b$ variant and a cost of at least 12.
PBKDF2 — not memory-hard, so it is much weaker against GPUs, and it remains the choice where FIPS validation is required. Use a high iteration count and know why you chose it.
Never: plain SHA-256, MD5, a single hash with a salt, or anything you designed.
ts
import argon2 from 'argon2';
const PARAMS = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };
export async function hashPassword(password: string) {
return argon2.hash(password, PARAMS); // (1)
}
export async function verifyPassword(user: User, password: string) {
const ok = await argon2.verify(user.passwordHash, password); // (2)
if (ok && argon2.needsRehash(user.passwordHash, PARAMS)) { // (3)
await saveHash(user.id, await argon2.hash(password, PARAMS));
}
return ok;
}(1) The library generates the salt and encodes it, the parameters and the hash into one string — so nothing extra is stored and old hashes remain verifiable after a parameter change. (2) verify reads the parameters out of the stored string and compares in constant time (Chapter 8.2.2). Never compare hashes yourself. (3) The upgrade path. When you raise the cost, existing hashes are re-computed at the next successful login, which is the only moment the plaintext exists. Without this, a parameter increase applies only to new users, and most systems never notice.
3. Password policy, as the evidence supports it
NIST SP 800-63B reversed decades of common practice, and the reversals are well supported.
Do:
- Require at least 8 characters, prefer 12+. Length dominates everything else.
- Allow at least 64 characters, and allow every character including spaces and emoji.
- Check against known-breached password lists. This is the single highest-value rule, because real attacks use real leaked passwords rather than brute force.
- Allow paste, and do nothing that breaks password managers.
- Offer to show the password. Typo-driven failures push people toward simpler passwords.
Do not:
- Composition rules. "One uppercase, one number, one symbol" produces
Password1!— predictable, and the transformations are in every cracking dictionary. - Forced periodic rotation. People increment:
Summer2026becomesSummer2027. Rotate on evidence of compromise, not on a calendar. - Truncate or silently strip characters. A password that works on registration and fails at login is usually this.
- Security questions. Mother's maiden name is public information. If regulation requires them, treat the answers as passwords and hash them.
Checking against breached lists without sending the password uses k-anonymity: SHA-1 the password, send only the first five hex characters of the hash, and receive every suffix that shares that prefix — typically a few hundred. You match locally. The service learns a prefix shared by thousands of passwords and never learns yours. It is an elegant design and worth understanding as a general pattern for private lookups.
4. Account enumeration
Account enumeration is learning whether an email address has an account, and it matters because it turns a generic credential-stuffing list into a targeted one, and because membership itself can be sensitive — a medical or dating service leaks something real by confirming an account exists.
Four places it leaks, and all four must be closed together:
Registration. "That email is already registered" is a direct answer. The alternative: accept the submission, and send an email — either "here is your verification link" or "someone tried to register with your address; here is a reset link if it was you". The browser shows the same message either way.
Login. "No such user" versus "wrong password" is the classic. Use one message: "Email or password is incorrect."
Password reset. "No account found" leaks. Always respond with "If an account exists for that address, we have sent a reset link."
Timing. This is the one people miss. If a missing account returns in 5 ms and an existing one takes 300 ms because a password hash was verified, the message does not matter. Fix it by doing the same work in both branches: verify the submitted password against a fixed dummy hash when no user exists, so the timing matches.
Be honest about the limits. Enumeration cannot always be eliminated — a signup form that must reject duplicates, an enterprise login that routes by domain — and it is a lower-severity issue than a weak password policy. Close it where it is cheap, and never let it delay the controls that matter more.
5. Login: rate limiting and lockout
Passwords are guessable, so the login endpoint needs limits. The design decision is what you limit, and getting it wrong creates a denial-of-service vulnerability.
Per-account lockout — five failures locks the account. It stops guessing at one account, and it lets an attacker lock out any user they can name. For a consumer product that is a serious availability problem; for a bank it may be an accepted trade.
Per-IP limiting — stops one source, and is defeated by a botnet. It also punishes shared addresses: an office or a mobile carrier's NAT (Chapter 5.3.3) is thousands of users behind one IP.
Both, with different thresholds, is the workable answer, plus two refinements:
Exponential delay rather than a hard lock. 1 s, 2 s, 4 s, 8 s. Guessing becomes impossible while a legitimate user with a slow memory still gets in.
Global limits on the number of accounts one source touches. Credential stuffing tries one password against a million accounts, so per-account counters never trigger. Watching "distinct usernames attempted per IP per hour" catches exactly that shape.
And the honest ordering: the defence against credential stuffing is not rate limiting, it is multi-factor authentication and breached-password checks (Chapter 8.4.7). The attacker has valid credentials; only a second factor stops them.
6. Password reset, designed properly
This flow is the most attacked part of most applications, because it is by definition a way to gain access without the password.
The rules, each with the attack it stops:
A reset token is at least 128 bits from a CSPRNG. Never derived from the user id, an email, a timestamp or a sequence — those are guessable, and guessable means enumerable.
Store only a hash of the token. A leaked database otherwise contains live account-takeover links. A fast hash is fine here: the token has full entropy, so slowness buys nothing.
Short expiry — 15 to 60 minutes. Tokens sit in inboxes indefinitely.
Single use. Delete or mark it used inside the same transaction that changes the password, so a double submission cannot reuse it.
Invalidate all outstanding tokens when one is used, and when the password changes.
Invalidate all sessions on password change. People reset because they think they are compromised; leaving the attacker's session alive defeats the entire exercise. Offer "keep me signed in on this device" explicitly. Chapter 8.4.2 covers how session invalidation works with stateless tokens, where it is genuinely harder.
Send only to the registered address, and never let the same flow change the address.
Do not log the user in automatically after a reset, and do not include the new password in any email.
Notify the account owner by email that the password changed — that message is what alerts a victim, and it must go to the old address too if the address was involved.
Rate limit reset requests per account and per address, or the form becomes an email-bombing tool.
ts
async function requestReset(email: string) {
const user = await findByEmail(email);
if (user) {
const token = randomBytes(32).toString('base64url'); // (1)
await db.resetTokens.insert({
userId: user.id,
tokenHash: sha256(token), // (2)
expiresAt: new Date(Date.now() + 30 * 60_000),
});
await sendResetEmail(user.email, token); // (3)
}
return { message: 'If an account exists, we have sent a link.' }; // (4)
}(1) 256 bits, URL-safe. (2) Only the hash is stored; the plaintext exists only in the email. (3) Sent to the address on record, never to the address in the request. (4) The same response either way, and the branch above must be time-equalised or the timing leaks what the message hides.
7. Email verification and changing an address
Verification proves the address belongs to the person. Without it, someone registers with your address and, later, a password reset delivers your account to them.
Same token rules as reset, with a longer expiry (24 hours is normal) and a resend that is rate limited.
Whether to allow use before verification is a product decision with a security floor: an unverified account must not be able to receive anything of value, appear as a verified identity to others, or send email on the platform's behalf.
Changing an email address needs both sides confirmed. Send a verification link to the new address and a notification to the old one with a way to reject the change. An account takeover almost always begins with changing the email, so the notification to the old address is the alarm that catches it. The change takes effect only when the new address is confirmed, and the old address keeps working until then.
8. Recovery, and the honest position on passwords
Recovery codes — ten single-use codes generated at enrolment, shown once, stored hashed exactly like passwords. They are what stops a lost second factor from becoming a lost account, and they must be single-use and regenerable.
Account recovery is the weakest link in any authentication system. Perfect passwords and hardware keys are undone by a support process that resets access after two questions. Design the recovery path with the same care as the login path, because an attacker will choose whichever is weaker — and social engineering of support staff is the most common route into a well-secured account.
The honest closing position: passwords are the weakest common authentication mechanism, and the best available answer to them is to reduce their importance rather than perfect them. Multi-factor authentication turns a stolen password into a nuisance (Chapter 8.4.7), and passkeys remove the password entirely. Everything on this page is how to run passwords responsibly while that transition happens.
What the interviewer will push on
"How do you store a password?" Argon2id with tuned parameters, per-user random salt (which the library encodes into the hash string), verified in constant time, with a rehash-on-login upgrade path so raising the cost applies to existing users. Mentioning the upgrade path unprompted is what separates a real answer, because almost every system forgets it.
"What is a salt, and what is a pepper?" The salt is a public per-user uniqueness device that kills rainbow tables and stops identical passwords sharing a hash. The pepper is a secret kept outside the database, so a database-only breach yields uncrackable hashes — and it buys nothing if the application server falls, and its cost is rotation.
"What password policy would you set?" Minimum 8, prefer 12, allow 64+ and all characters, check against breached lists, allow paste. No composition rules and no forced rotation, because both produce predictable passwords. Then explain the k-anonymity prefix lookup, which shows you know how the breach check works without sending the password.
"How do you prevent account enumeration?" Identical responses on registration, login and reset — and identical timing, which means verifying against a dummy hash when no user exists. Then be honest that it cannot always be eliminated and is lower severity than the controls it must not delay.
"Design a password reset flow." 128+ bit CSPRNG token, stored hashed, short expiry, single use inside the same transaction, all sessions invalidated on change, notification to the old address, sent only to the registered address, rate limited, and no automatic login afterwards. The reasoning that stands out is session invalidation: people reset because they suspect compromise.
"Account lockout after five failures — good or bad?" It stops targeted guessing and hands an attacker a way to lock out any named user. Prefer exponential delays plus per-IP limits plus a distinct-accounts-per-source counter, because credential stuffing never triggers per-account thresholds. Then name the real defence: MFA, since the attacker already has valid credentials.
One thing to volunteer: point out that account recovery is where well-secured accounts are actually lost — a support process that resets access after two questions defeats hardware keys. Designing recovery with the same rigour as login, and treating an email-address change as a takeover signal, is the observation that shows operational experience.
Recall
- Store passwords with a deliberately slow, memory-hard KDF: Argon2id (tune memory first, target 250–500 ms), or scrypt; bcrypt is acceptable but silently truncates at 72 bytes; PBKDF2 only for FIPS. Never a plain fast hash.
- Salt is public, per-user, and kills rainbow tables and shared hashes. Pepper is a secret outside the database — it defeats a database-only breach and nothing more, and rotation is its cost.
- Always implement rehash-on-login, or a raised cost only ever applies to new users.
- Policy: 8+ (prefer 12), allow 64+ and all characters, check breached lists via the k-anonymity prefix lookup, allow paste. No composition rules, no forced rotation — both produce predictable passwords.
- Account enumeration leaks at registration, login, reset and in timing — equalise by verifying against a dummy hash when no user exists. Close it cheaply; never let it delay MFA.
- Login limits: per-account and per-IP, exponential delay over hard lockout (which is a denial-of-service gift), plus distinct accounts attempted per source to catch credential stuffing. The real defence against stuffing is MFA.
- Reset tokens: 128+ bits from a CSPRNG, stored hashed, 15–60 minutes, single use in the same transaction, all sessions invalidated on change, notification to the old address, never auto-login, rate limited.
- An email-address change is an account-takeover signal: confirm to the new address and notify the old with a reject link. Account recovery is the weakest link — design it with login's rigour.
Self-test: What does memory-hardness defeat that iteration count does not? · Why is a salt not a secret, and a pepper useless after an app-server breach? · What does rehash-on-login fix? · Why does a uniform error message not stop enumeration on its own? · Why must a password change kill existing sessions? · What is the k-anonymity trick in a breached-password check?
Next: 8.4.2 covers what happens after a successful login — sessions versus tokens, JWT anatomy, and why revoking a stateless token is genuinely hard.