--- title: Password reset by emailed code summary: A "forgot password" flow that works inside installed PWAs — a six-digit code typed back into the app, hashed, rate-limited, enumeration-proof, ending every old session. when_to_use: An app has email + password login and no way back in for someone who forgot the password; or an existing reset logs people in to the wrong browser, leaks which emails have accounts, or 404s on refresh. prerequisites: Working email sending (workflows/email-sending.md), a server-side session table, the password hash signup already uses. Examples use Fastify + Prisma + zod + argon2 and React; carry the decisions, not the code. related: [workflows/email-sending.md, workflows/browser-flow-testing.md, skills/add-password-reset/SKILL.md] last_verified: 2026-09-26 --- # Password reset by emailed code ## Goal `/forgot` → the user types their email → gets a six-digit code by email → types the code and a new password **in the same app** → every existing session for that account ends and this device is logged in. ## Why a code and not a link The usual answer is a link. For an app people install to the home screen it is the wrong one: - On iOS a home-screen web app has **its own storage container, separate from Safari's — for the same origin.** A link tapped in Mail opens Safari, the new session cookie lands there, and the user goes back to the app icon still logged out, holding a password they set thirty seconds ago. Gmail's in-app browser splits the same way. - A code is typed into the app that asked for it, so the login lands **there**. - It is also less code: no token route, no expired-link page, no reset page that can 404. If desktop users matter, add a link **beside** the code, never instead of it. "It's the same origin, so the PWA is fine" is exactly the mistake. ## Inputs (placeholders used) | Placeholder | Meaning | |---|---| | `` | the app | | `` | its domain; mail comes from `noreply@` | | `User`, `AuthSession` | your existing user and session models | ## Steps ### 1. Check how the server serves client routes — before writing the page A new React route (`/forgot`) that the server doesn't know works when you navigate to it in the app and 404s on refresh or bookmark. Find where SPA routes are declared (an nginx `try_files … /index.html`, or an allowlist in the API server) and make sure `/forgot` will be served. **Check:** `curl -s -o /dev/null -w '%{http_code}' -H 'Accept: text/html' https:///login` returns 200. (Without the `Accept` header some servers 404 every SPA route — test a known route the same way so you don't chase a bug that isn't there.) ### 2. Add the model — one row per user ```prisma model PasswordReset { userId String @id // one live code per user; asking again replaces it codeHash String // slow hash of the six digits expiresAt DateTime attempts Int @default(0) // wrong tries against this code sentCount Int @default(1) // codes sent in the current window windowStartedAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) } ``` **Check:** migration applies on a scratch DB; deleting a user deletes the row. ### 3. Write the pure rules in one small module (and unit-test it) ```ts import { randomInt } from "node:crypto"; export const CODE_TTL_MS = 30 * 60 * 1000; // long enough to find the mail export const MAX_ATTEMPTS = 5; // then the code is thrown away export const MAX_PER_WINDOW = 3; // codes per account per hour: no mail-bombing export const WINDOW_MS = 60 * 60 * 1000; // randomInt is uniform; random()*n and randomBytes % n are skewed. export const newCode = () => String(randomInt(0, 1_000_000)).padStart(6, "0"); // A code pasted from an email may arrive as "418 392". export const normaliseCode = (s: string) => s.replace(/\D/g, ""); ``` Email body: show the code grouped (`418 392`), say how long it lasts, say "if you didn't ask, ignore this". **Check:** tests for 6 digits with leading zeros, normalisation, constants. ### 4. `POST /auth/forgot` — always `{ ok: true }` Rules, all required: - Answer **the same** whether or not the account exists. - **Hash the code before looking the user up.** Skipping a slow hash for unknown addresses makes them answer measurably faster — a timing leak that says who has an account. - Over the hourly allowance: answer ok and send nothing. - Send the mail **after** building the reply, not awaited. - Outside production, return the code as `devCode` (local dev and browser flows have no inbox). ```ts app.post("/auth/forgot", rateLimited, async (request) => { const { email } = parse(forgotBody, request.body); const code = newCode(); const codeHash = await hashPassword(code); // before the lookup: equal timing const user = await prisma.user.findUnique({ where: { email }, select: { id: true } }); const ok: { ok: true; devCode?: string } = { ok: true }; if (!user) return ok; const now = Date.now(); const existing = await prisma.passwordReset.findUnique({ where: { userId: user.id } }); const windowOpen = !!existing && now - existing.windowStartedAt.getTime() < WINDOW_MS; if (windowOpen && existing.sentCount >= MAX_PER_WINDOW) return ok; const row = { codeHash, expiresAt: new Date(now + CODE_TTL_MS), attempts: 0, sentCount: windowOpen ? existing.sentCount + 1 : 1, windowStartedAt: windowOpen ? existing.windowStartedAt : new Date(now), }; await prisma.passwordReset.upsert({ where: { userId: user.id }, create: { userId: user.id, ...row }, update: row }); void getEmailer().send({ to: email, ...resetEmail(code) }) .catch((err) => request.log.error({ err }, "reset email not sent")); if (process.env.NODE_ENV !== "production") ok.devCode = code; return ok; }); ``` **Check:** unknown and known addresses both return `{"ok":true}`; in production the body has no `devCode`. ### 5. `POST /auth/reset` — one message for every failure Every failure (unknown email, no code, expired, wrong digits, out of tries) returns the **same** 400: *"That code is wrong or has expired. Ask for a new one."* - Unknown user: still run a verify against a dummy hash, so timing matches. - **Spend the try before checking the code, in one conditional write.** Reading `attempts`, hashing, then writing back lets ten parallel guesses all read 0. - **One use only:** whichever request deletes the row owns the reset. - On success: in one transaction set the new hash **and delete every session** for the user; then start a new session on this device and set the cookie. Resetting a password is how you throw an intruder out, and the person is standing in the app — don't send them to the login screen. ```ts app.post("/auth/reset", rateLimited, async (request, reply) => { const body = parse(resetBody, request.body); const code = normaliseCode(body.code); const wrong = new HttpError(400, "That code is wrong or has expired. Ask for a new one."); const user = await prisma.user.findUnique({ where: { email: body.email }, select: { id: true } }); if (!user) { await verifyPassword(code, null); throw wrong; } // dummy hash inside const now = new Date(); const taken = await prisma.passwordReset.updateMany({ where: { userId: user.id, attempts: { lt: MAX_ATTEMPTS }, expiresAt: { gt: now } }, data: { attempts: { increment: 1 } }, }); const reset = taken.count === 1 ? await prisma.passwordReset.findUnique({ where: { userId: user.id } }) : null; const dead = { userId: user.id, OR: [{ attempts: { gte: MAX_ATTEMPTS } }, { expiresAt: { lte: now } }] }; if (!reset) { await prisma.passwordReset.deleteMany({ where: dead }); await verifyPassword(code, null); throw wrong; } if (!(await verifyPassword(code, reset.codeHash))) { if (reset.attempts >= MAX_ATTEMPTS) await prisma.passwordReset.deleteMany({ where: dead }); throw wrong; } const used = await prisma.passwordReset.deleteMany({ where: { userId: user.id, codeHash: reset.codeHash } }); if (used.count === 0) throw wrong; const passwordHash = await hashPassword(body.password); await prisma.$transaction([ prisma.user.update({ where: { id: user.id }, data: { passwordHash } }), prisma.authSession.deleteMany({ where: { userId: user.id } }), ]); await startSession(reply, user.id); return { ok: true }; }); ``` **Check (API tests):** wrong code ×5 kills the code; the 6th try with the right code fails; right code succeeds once and fails the second time; a session created before the reset is gone after it; 3 sends per hour then silence. ### 6. The screen — one page, two steps - "Forgot password?" link on the login screen, **carrying the typed email over**. - Step 1: email → "Send me a code". Step 2: says *which inbox* the code went to; code field (`inputmode="numeric" autocomplete="one-time-code"`), new password, "Send a new code" link. - A wrong code keeps the user on step 2 with the error; it doesn't reset the form. - On success: land on the app's home, logged in. - Outside production, pre-fill the code field from `devCode` (the browser flow reads it from there). - Tap targets at least 40 px tall; a text link inside a label row is usually ~28 px. **Check:** at 390 px wide nothing scrolls sideways. ### 7. Browser flow — local dev only Write a headless flow (see workflows/browser-flow-testing.md): sign up a fresh `flow-@example.com` user, log out, forgot link carries the email, ask for a code, wrong code refused without losing the screen, right code logs in, old password rejected, new one works, same code refused a second time. **Make the flow refuse to run against the production origin** — it changes a password and depends on `devCode`. ### 8. Prove it on production once With a throwaway account on a `+tag` of your own inbox: request a code, confirm the mail is in **Inbox**, from `noreply@`, `signed-by: `; complete the reset; then delete the account. **Check:** `curl -s -X POST https:///api/auth/forgot -H 'content-type: application/json' -d '{"email":"nobody@example.com"}'` returns exactly `{"ok":true}` — no `devCode`. ## Traps | Symptom | Cause | Fix | |---|---|---| | User resets from the email and the installed app is still logged out | a link opened Safari/in-app browser, which has its own cookie jar | use a code typed in the app | | `/forgot` works via the link but 404s on refresh | SPA route not registered on the server | add it to the server's SPA routes / `try_files` (step 1) | | `curl https:///login` 404s too | no `Accept: text/html` header | not a bug; add the header | | Unknown emails answer ~100 ms faster | hash skipped when the user is missing | hash first / verify a dummy hash | | Ten parallel guesses cost one try | read-modify-write of `attempts` around a slow hash | conditional `updateMany` increment first | | Deploy dies after the build | unquoted `EMAIL_FROM` with `<` in a sourced `.env` | quote it | | Reset flow changed a real user's password | flow pointed at production | refuse the live origin in the flow | ## Done when - [ ] Route served on refresh (checked with `Accept: text/html`) - [ ] Model: one row per user, hashed code, 30 min, 5 tries, 3/hour - [ ] `/auth/forgot` always ok, hashes before lookup, mail after reply, `devCode` only outside production (with a test) - [ ] `/auth/reset` one generic error, atomic try, single use, all sessions ended, this device logged in - [ ] API tests for every rule above - [ ] Browser flow passes locally and refuses production - [ ] One real reset on production reached the Inbox signed by ``; throwaway account deleted