A password reset link is a password: anyone holding it can take the account, and it arrives by email.
The previous lessons hardened the session in the browser. This lesson is about the flows that create sessions in the first place, which is where an attacker goes when stealing one becomes hard. If you have arrived here directly, the carried-over ideas are that a bearer token needs 128 bits of entropy and that a stored secret should be stored as a hash of itself.
Every one of these flows mints a credential
Sign-up, email verification, password reset, changing an email address, changing a password, adding a second factor. They look like product features and they are all the same security object: a mechanism that turns possession of a mailbox into control of an account.
That framing settles most design questions at once. If the reset link is a credential, then it needs the entropy of a credential, the expiry of a credential, the storage of a credential, and single use. If control of the mailbox yields control of the account, then no part of your authentication is stronger than the user's email provider, which is a genuine limit worth saying out loud to yourself before you design a second factor around it.
It also explains the ordering rule that catches teams repeatedly. Any flow that changes where credentials are delivered, or what they are, must be gated on the current credential. Changing an email address without asking for the password lets an attacker with a borrowed session move the account's recovery channel to their own mailbox, at which point every recovery flow you built works perfectly for them.
One token, hashed, single use, short lived
All the email-borne flows share one object, and it is worth writing once.
async function issueToken(kind, userId, minutes) {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const token = bytes.toBase64({ alphabet: "base64url" });
await db.deleteTokens({ kind, userId }); // one live flow at a time
await db.insertToken({
kind, // "verify" | "reset"
userId,
hash: await sha256(token), // never the token
expiresAt: Date.now() + minutes * 60_000,
usedAt: null
});
return token; // goes in the email, nowhere else
}Five properties, each of which fails a real system when it is missing.
Entropy. Thirty-two random bytes. Nothing derived from the user ID, the email address or the time, because anything derivable is guessable.
Hashed at rest. The tokens table is a database table with the same leak risk as any other, and a leaked reset token is an account. Fast SHA-256 is correct here for the same reason as with session tokens: the input is 256 bits of uniform randomness, so there is no candidate list to search.
Expiry. Fifteen to sixty minutes for a reset. Not because the token becomes guessable, since guesses per second for a full hour explores candidates out of , but because the token sits in a mailbox, and mailboxes are read by people other than their owner: a shared laptop, a synced device given to a child, a corporate archive, an ex-partner. Expiry limits how long that copy is dangerous.
Single use. Mark usedAt and refuse a second presentation, in the same transaction that applies the change, so two concurrent requests cannot both succeed. Without this, the link in the mailbox stays live after the user has finished with it.
One live flow at a time. Issuing a new reset invalidates the previous one. Otherwise a user who clicks "forgot password" five times leaves five live credentials in their mailbox, and an attacker with any one of them wins.
Sign-up and verification
Sign-up has one job beyond creating the row: establishing that the person controls the address they claimed. Skip it and you have accounts that cannot be recovered, mail that bounces, and a way for an attacker to squat on somebody else's address before they arrive.
The safe shape is to create the account in an unverified state, send a verification link, and withhold whatever matters until it is clicked. What "matters" is a product decision, and the useful line is anything visible to other people or costly to undo: posting, inviting, receiving notifications from other users. Blocking the entire product until verification loses users who typed their address correctly and are waiting on a slow mail server.
There is a specific attack the unverified state must handle, sometimes called pre-hijacking. An attacker signs up with [email protected] and never verifies. The real owner later arrives, cannot register because the address is taken, and uses "forgot password" instead, verifying the address in the process. If your reset flow lands them in the attacker's pre-created account, and the attacker had already added their own second factor or an OAuth link, the attacker retains a way in. Two rules close it: an unverified account holds no state that survives verification by a different person, and completing a reset on an unverified account clears any second factor and any linked identity provider that was added before verification.
Verification links are consumed by things that are not the user. Corporate mail scanners fetch every URL in a message to check it for malware, which means a GET /verify?token=... may be consumed before the user sees the email. The fix is to make the link land on a page that shows a button, and perform the change on the POST. This is the same rule as the previous lesson's, that GET must not have side effects, arriving from a completely different direction.
Example. A team issues reset tokens as base64(userId + ":" + Date.now()), valid for 24 hours, stored in plain text, and not invalidated on use. Enumerate what an attacker can do.
Four separate attacks. The token is not random at all, so an attacker who knows the user ID and the approximate second of the request reconstructs it, and even without the second, a day's worth of milliseconds is candidates, walkable at any tolerated request rate. It is stored in plain text, so a leaked table is a set of live account takeovers. Twenty-four hours is a long time for a credential sitting in a mailbox. And because it is not consumed, the link keeps working after the user has reset, so anyone who later reads that mailbox takes the account. Any one of the four is enough on its own.
Now you. A verification link is GET /verify?token=... and marks the address verified when fetched. Users report that their account is sometimes verified before they click. What is happening, and what is the fix?
Answer
Something machine-driven is fetching the URL: a corporate mail gateway scanning links for malware, an antivirus client, or the mail client prefetching for a preview. The verification therefore happens without the user, which defeats the point of proving a person read the mailbox, and consumes a single-use token so the user's own click fails. Make the link a GET that renders a page with a "Confirm my email" button, and perform the verification on the POST from that button. Scanners issue the GET and stop.
Reset, and the two rules people break
Password reset is the flow attackers reach for first, because it is designed to hand over an account to someone who has forgotten how to prove they own it.
The flow is: request with an email address, always answer the same way, send a link only if the account exists, land on a page with a form, and on submission validate the token, change the password, consume the token, and invalidate every existing session for that user.
Two rules inside that are broken constantly.
A reset must not sign you in. It is tempting: the user proved control of the mailbox, so why make them type the password they just chose. The reason is that the entire strength of the flow rests on the mailbox, and a token in an inbox is a weaker credential than a password chosen and typed. Also, the person clicking may not be the person who requested it, since a reset can be requested for anyone by anyone. Complete the reset, then send them to the login form.
A reset must end every other session. The common case is a user resetting because they believe they are compromised. If the attacker's session survives the reset, the reset accomplished nothing at all. Delete every session row for that user, revoke every refresh token family, and while you are there, send a notification to the address on file saying the password changed, because that mail is how a victim finds out.
Password change while signed in has a symmetric rule pointing the other way: require the current password, then invalidate every session except the current one, or the user signs themselves out by changing their password, learns that changing the password is annoying, and stops doing it.
Email change deserves its own care because it moves the recovery channel. Require the current password, send a confirmation link to the new address to prove the user controls it, and send a notice to the old address with a way to object. Do not switch the address of record until the new one is confirmed, so a typo does not orphan the account. The notice to the old address is the part most often skipped, and it is the only thing that tells a victim their account has been taken.
Enumeration, and how much of it you can actually fix
Every flow so far answers a question the attacker wants answered: does an account exist for this address. That matters because it turns an untargeted list into a targeted one, tells a phisher which service to impersonate, and reveals membership of a site somebody may not want to be known to use.
Six places leak it, and each needs a deliberate answer.
Login says "no such user" instead of "wrong password", so use one message and one status for both. Login timing then leaks the same thing by returning fast when the expensive hash never ran, which the dummy hash from the passwords lesson fixes. Reset request says "we have sent you an email" only when the account exists, so say it always and send nothing when it does not. Rate limit responses differ if you apply a per-account limit only to accounts that exist.
Sign-up is the hard one, because "that email is already registered" is genuinely useful and the honest alternative is confusing. The approach that works is to accept the sign-up silently and branch in the mail: a new address receives the verification link, an existing one receives a message saying somebody tried to register with this address and offering a reset link. The experience stays coherent and the response reveals nothing.
Side channels leak it anyway: a profile page at /u/ada that 404s for absent users, a search box, an invite flow that reports "already a member".
Be honest about what is achievable. A determined attacker with a large list and a target service often gets enumeration from somewhere, because the surface is wide and the product usually needs to say something true at some point. The goal is to make it cost something and to close the cheap paths, not to claim perfection. Where the information is genuinely sensitive, an anonymous mental health service, say, the product design has to change too, not just the error strings.
Example. A sign-up form returns HTTP 409 with "That email is already registered". Rewrite the flow so it reveals nothing, and say what the user sees in each case.
Return the same 200 and the same page in both cases: "Check your inbox to finish signing up." Then branch in the mail. A new address receives the verification link and completes registration normally. An existing address receives a message saying somebody attempted to register with this address, that an account already exists, and offering a sign-in link and a reset link. The legitimate user who forgot they had an account is helped rather than confused, and an attacker probing addresses learns nothing from the response, since it is byte-identical either way. Make sure the timing matches too: both paths must send a mail and take the same work.
Now you. A reset endpoint returns 200 with "Check your email" for every address, but responds in 40 ms for unknown addresses and 300 ms for known ones. What leaks, and what is the cause?
Answer
The whole thing leaks: 40 against 300 milliseconds is a wide and reliable signal, so the identical response body buys nothing. The cause is that the known path does real work, generating a token, writing a row and handing off to the mail queue, while the unknown path returns immediately. Fix it by doing comparable work in both branches, or better, by making the response independent of the work: accept the request, return the response, and queue the token generation and mail as a background job so both paths return in the same few milliseconds.
Rate limiting that survives contact with a botnet
Rate limiting is what stops guessing, and the naive form is aimed at an attacker who no longer exists.
Limit by IP address alone and do the arithmetic. A credential stuffing run has a million email and password pairs from other breaches and a pool of 50,000 residential proxy addresses. That is attempts per address, spread over hours. A limit of 100 attempts per IP per hour never triggers once. Meanwhile the same limit blocks a university, an office behind one NAT gateway, or a mobile carrier's shared address, so it inconveniences exactly the people who are not attacking you.
Limit by account alone and you have handed the attacker a denial of service, dealt with below.
The workable arrangement uses several counters at once, each aimed at a different attacker.
Per account, per window. Five failed logins in fifteen minutes, then escalating delay. This stops a targeted guess against one person, and it is the counter an attacker with a large IP pool cannot dodge, because the target is fixed.
Per IP, generously. A high limit a household never reaches but a single-address brute force does. A backstop, not the main defence.
Globally, on failure rate. What actually detects credential stuffing is the ratio of failed to successful logins across the whole service. Normal traffic holds a stable ratio; a stuffing run drives it up sharply within minutes however the source addresses are spread. Alert on it, and prepare a response such as requiring a second factor from everyone.
Per credential pair. Remember recent failures by the hash of the attempted email and password, and refuse a repeated pair. Stuffing replays the same pairs across many services and often within one.
For the per-account counter, exponential backoff beats a hard lockout. Delay the response by one second after the first failure, two after the second, four after the third, doubling to a cap. Ten consecutive failures cost seconds, just over 17 minutes of accumulated waiting, and the eleventh attempt waits 1024 seconds on its own. A legitimate user who mistypes twice notices nothing; a guessing attack dies. Store the counter keyed on the account, reset it on a successful login, and apply the delay before the password hash runs so you are not paying 300 ms of CPU per attempt to punish an attacker.
Lockout and CAPTCHA, and what they actually buy
Account lockout after N failures is the intuitive control and it is a denial of service you operate against your own users. An attacker who wants to lock out ten thousand accounts sends five deliberately wrong passwords to each, which is 50,000 requests, a few minutes of work, and now ten thousand people cannot sign in and your support queue is the real damage. If you must have a hard lockout, make it temporary and short, minutes rather than hours, and prefer the escalating delay, which imposes the same cost on the attacker without the cliff.
There is one place where a lock is right: when the evidence points at a compromised account rather than a guessing attempt, such as a successful login from a new country immediately followed by a change of email and a bulk export. That is a response to detection, not a response to a counter.
CAPTCHA is widely misunderstood. It does not verify humanity; solving services price a thousand solves in the low single digits of dollars, so a determined attacker treats it as a small tax. What it does is break automation cheaply enough to be worth having: it stops the trivial script, and it raises the cost per attempt by enough to make a large campaign uneconomic when combined with the counters above. Show it conditionally, after a few failures or when the global failure ratio is elevated, rather than to everyone on every login, because the accessibility cost of a challenge on every visit is real and falls on people who cannot easily route around it.
Example. A service limits logins to 100 per IP per hour and locks an account for one hour after five failures. An attacker has 50,000 proxy addresses and one million stolen credential pairs. What happens?
The IP limit never fires: a million attempts across fifty thousand addresses is twenty per address, well under 100. The lockout does fire, but only for accounts where the attacker guesses wrong, so the attacker's correct guesses succeed and the wrong ones lock out innocent users. If a typical stuffing run succeeds on around 0.1 percent of pairs, roughly 1,000 accounts are taken and something close to the other 999,000 are locked out, which is a service-wide outage caused by the defence. The IP limit is aimed at the wrong adversary and the lockout has been turned into the attacker's weapon.
Now you. Replace those two controls with something that works against the same attacker, and say what each part does.
Answer
Keep a generous per-IP limit as a backstop, since it costs nothing and stops the single-address script. Add per-account exponential backoff instead of lockout, so the attacker's wrong guesses cost them time and cost the account owner nothing but a delay they will never encounter. Add a per-credential-pair memory so a replayed pair is refused outright. Then monitor the global ratio of failed to successful logins, which is the only signal that reveals the campaign as a whole, and on a spike require a second factor or a challenge across the board. The parts that matter are the ones keyed to something the attacker cannot rotate: the account and the credential pair. The IP address is theirs to change.
What is left standing
Assume all of it is right. Tokens are random, hashed, short lived and single use. Responses reveal nothing. Backoff makes guessing pointless and the global signal catches campaigns.
There is still exactly one secret between an attacker and any account, and it is the password. Phishing gets it in a minute from a convincing page. Reuse gets it for free from somebody else's breach. Malware on the user's machine reads it as they type. None of the work in this lesson touches any of those, because none of them involves guessing.
The answer is to require a second, independent thing, so that knowing the password is not enough. That is next, and the honest version of it comes with a warning: the most common second factor is itself phishable, which is what makes the lesson after it necessary.