Lesson 15 / 23
Account Recovery
The password reset flow's attack surface: the token being randomly generated, stored as a hash, single-use, and short-lived, returning the same response against user enumeration, and what a reset does to sessions.
Contents
The previous lesson established how a password gets chosen. This one takes on the case where it gets forgotten — a second door bypassing the system’s most carefully built authentication flow entirely. When a library member cannot recall their password, the system has to recognize them without one. How that happens is a boundary built on top of every defense from the previous three lessons: if recovery is weak, it does not matter how well the password was stored.
The flow has three steps: the user reports an address, the system sends a value to that address that only someone who can reach it will receive, and whoever brings it back sets a new password. The whole lesson is about the properties of that value — the reset token — and what the first step’s response gives away.
The Token’s Four Properties
A reset token is a short-lived credential, and it demands the same care as a password.
Randomly generated. A token derived from a member number, a timestamp, or a sequential counter is predictable; the source is a cryptographic random number generator, at least thirty-two bytes long.
Stored as a hash. What sits in the database is not the token but its hash — the same reasoning as a password hash: anyone who can read the database should not be able to use a pending reset request. The token’s high entropy means a slow hash is not needed; a single-pass hash is enough.
Single-use. It closes on first use. A token that does not close is enough for someone who later gains access to the mailbox to seize the account all over again.
Short-lived. A fifteen-minute window is standard; the measure is how long it takes the user to open their email and click the link. A long lifetime turns the token into a standing key waiting in the mailbox.
The script below sets up all four properties together and proves the last two with queries.
cat > recovery.mjs <<'EOF' import { randomBytes, createHash } from "node:crypto"; import { DatabaseSync } from "node:sqlite"; const db = new DatabaseSync(":memory:"); db.exec(`CREATE TABLE reset( hash TEXT PRIMARY KEY, member TEXT NOT NULL, issued INTEGER NOT NULL, expires INTEGER NOT NULL, used INTEGER)`); const hash = (b) => createHash("sha256").update(b).digest("hex"); const now = () => Date.now(); const LIFETIME_MS = 15 * 60 * 1000; // 15 minutes function issue(member, lifetime = LIFETIME_MS) { const token = randomBytes(32).toString("base64url"); db.prepare("INSERT INTO reset VALUES (?,?,?,?,NULL)") .run(hash(token), member, now(), now() + lifetime); return token; // goes out only in the link } function redeem(token) { const record = db.prepare("SELECT * FROM reset WHERE hash = ?").get(hash(token)); if (!record) return "invalid"; if (record.used !== null) return "already used"; if (now() > record.expires) return "expired"; db.prepare("UPDATE reset SET used = ? WHERE hash = ? AND used IS NULL") .run(now(), record.hash); return `accepted: ${record.member}`; } const valid = issue("U-1001"); const stale = issue("U-1005", -1000); // expired the instant it was issued console.log("issued token (goes out in the link):", valid.slice(0, 16) + "..."); console.log("stored in the database :", db.prepare("SELECT hash FROM reset WHERE member='U-1001'").get().hash.slice(0, 16) + "..."); console.log("is the token in the table:", db.prepare("SELECT COUNT(*) c FROM reset WHERE hash = ?").get(valid).c > 0); console.log("\nfirst use :", redeem(valid)); console.log("second use :", redeem(valid)); console.log("expired token :", redeem(stale)); console.log("unknown token :", redeem(randomBytes(32).toString("base64url"))); console.log("\nrecord state:"); for (const r of db.prepare( `SELECT member, substr(hash,1,10) h, (expires - issued)/1000 lifetime_s, CASE WHEN used IS NULL THEN 'open' ELSE 'closed' END state FROM reset ORDER BY member`).all()) { console.log(` ${r.member} ${r.h} lifetime=${r.lifetime_s} s state=${r.state}`); } EOF node recovery.mjs
issued token (goes out in the link): azp2j5foOE_Ya9fl... stored in the database : d4fa354bedb75dd4... is the token in the table: false first use : accepted: U-1001 second use : already used expired token : expired unknown token : invalid record state: U-1001 d4fa354bed lifetime=900 s state=closed U-1005 a78c706926 lifetime=-1 s state=open
Token values change on every run; the meaning of the lines does not. The third line confirms the storage decision: querying for the token itself finds no record, since the table holds its hash, not the token.
The next four lines show the token’s status chart: first use accepted, second rejected, expired rejected, never-issued rejected. Each outcome is a separate code path needing its own test; the second is the one most often skipped.
The AND used IS NULL condition is not a detail. If two concurrent requests bring in
the same token, an unconditional update accepts both; a conditional one lets only one
change the row — the Advanced SQL course’s conditional-update pattern, showing up here.
User Enumeration
The flow’s first step is the user reporting an address. The server’s response should not say whether that address is registered. If it does, the flow becomes a membership-lookup tool: someone trying a list of addresses learns which ones belong to library members. This is called user enumeration.
What leaks is not a password, but it is not trivial either — for a library, membership is personal information, and in other domains the same leak has far graver consequences.
The block below runs two setups side by side. The first is the setup that should be rejected, included only for comparison.
cat > recovery-server.mjs <<'EOF' import { createServer } from "node:http"; import { randomBytes, createHash, scryptSync } from "node:crypto"; const MEMBERS = new Map([["[email protected]", "U-1001"], ["[email protected]", "U-1005"]]); const BASE_MS = 120; // baseline time spent on both branches const body = (request) => new Promise((resolve) => { let v = ""; request.on("data", (p) => (v += p)); request.on("end", () => resolve(JSON.parse(v || "{}"))); }); function issueToken(member) { // the real cost: generation + hash const t = randomBytes(32).toString("base64url"); scryptSync(t, member, 32, { N: 2 ** 12, r: 8, p: 1 }); return createHash("sha256").update(t).digest("hex"); } createServer(async (request, response) => { const path = new URL(request.url, "http://local").pathname; if (path === "/health") return response.writeHead(200).end("ready\n"); const { email } = await body(request); const member = MEMBERS.get(email); if (path === "/flawed/reset") { // the setup that should be rejected if (!member) return response.writeHead(404, { "content-type": "application/json" }) .end(JSON.stringify({ error: "unregistered_address" }) + "\n"); issueToken(member); return response.writeHead(200, { "content-type": "application/json" }) .end(JSON.stringify({ status: "sent", member }) + "\n"); } const start = Date.now(); if (member) issueToken(member); else issueToken("none"); // same work on both branches const remaining = BASE_MS - (Date.now() - start); setTimeout(() => { response.writeHead(202, { "content-type": "application/json" }) .end(JSON.stringify({ status: "request received" }) + "\n"); }, remaining > 0 ? remaining : 0); }).listen(8494, "127.0.0.1"); EOF node recovery-server.mjs & server=$! until curl -sf http://127.0.0.1:8494/health > /dev/null; do :; done attempt() { curl -s -X POST "http://127.0.0.1:8494$1" -H 'content-type: application/json' \ -d "{\"email\":\"$2\"}" -w ' HTTP %{http_code} time=%{time_total} s\n' } echo "--- the setup that should be rejected ---" echo "registered address:"; attempt /flawed/reset [email protected] echo "unregistered address:"; attempt /flawed/reset [email protected] echo "--- correct setup ---" echo "registered address:"; attempt /reset [email protected] echo "unregistered address:"; attempt /reset [email protected] kill $server rm -f recovery-server.mjs
--- the setup that should be rejected ---
registered address:
{"status":"sent","member":"U-1001"}
HTTP 200 time=0.007614 s
unregistered address:
{"error":"unregistered_address"}
HTTP 404 time=0.000665 s
--- correct setup ---
registered address:
{"status":"request received"}
HTTP 202 time=0.122979 s
unregistered address:
{"status":"request received"}
HTTP 202 time=0.123334 s
Timing changes with the machine; the pattern of divergence does not. The rejected setup leaks through three separate channels.
First, the status code: 200 versus 404 names two outcomes. Second, the body — for a registered address, even the member number comes back. Third, timing: generating a token for a registered address leaves a measurable delay, while an unregistered one returns instantly. Fixing two of three still leaves the leak; all three are handled together.
In the correct setup, all three channels are closed: same code, same body, same timing. The server does the same work on both branches and settles onto a fixed baseline time. The code is 202, because it says “request received” — “sent” would give away that it could be.
The same rule holds at the second step. Invalid, already-used, and expired tokens all get the user one message: “this link is no longer valid, request a new one.” Separate messages would report the token’s exact state and, indirectly, whether the account exists. The logs keep the distinction — operations still needs to know which path ran.
Carrying the Token
The token travels inside a link, and that transport has its own rules.
The address can leak to third parties through the referrer header the moment it is visited. The reset page should not load outside resources, and its referrer policy should be strict — the same decision the Client-Side Security topic already made applies here directly.
The second rule concerns logs: address components get written to the server’s access logs, and a token traveling in the query string lands there too. The redaction rule from the Server-Side Fundamentals course has to cover reset links as well.
Third, the reset page must not hold onto the token permanently. It is read once when the page opens, used when the new password is submitted, and cleared from the address bar.
Side Effects of a Reset
A successful reset does more than change the password.
Open sessions are terminated. A password reset means the account may have been compromised; closing every session tied to that account is an inseparable part of the reset. How that is done is the next lesson’s subject.
Other open reset tokens are closed. More than one request may have been made for the same account; the unused ones are invalidated in the same operation.
The user is notified. The registered address gets a separate notice that the password was changed. This notice is the only mechanism that lets the user notice if they were not the one who did it.
No automatic sign-in. After the reset, the user goes through the normal login flow with the new password. Having the reset flow open a session directly would put the recovery door on equal footing with the login door.
Finally, the request step is subject to rate limiting. The limit applies per target address and per source address; otherwise the flow becomes a scalable tool for trying address lists. The response when the limit is exceeded must not diverge either — the same 202, the same body.
Summary
- A reset token is randomly generated, stored as a hash, single-use, and short-lived; each of the four properties is tested in its own code path.
- Storing the token as a hash keeps anyone who can read the database from using pending reset requests.
- User enumeration leaks through three channels — status code, body, and timing; the leak continues until all three are closed together.
- The token travels in a link; the referrer header, access logs, and address bar are this transport’s leak points.
- A successful reset closes open sessions and other tokens, notifies the user, and does not sign them in automatically.
Next Step
This lesson’s last rule raises the next one’s question: what does “close every session tied to the account” actually mean? If the session identifier sits in the browser, how does the server invalidate it, and where does the user see which devices they signed in from? The next lesson moves to session security: renewing the identifier at login, listing and individually terminating concurrent sessions, and enforcing idle and absolute lifetime limits together.
To keep your progress and take notes, Log in
My notes
Log in to take notes.