/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.
The usual answer is a link. For an app people install to the home screen it is the wrong one:
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.
| Placeholder | Meaning |
|---|---|
<app> |
the app |
<domain> |
its domain; mail comes from noreply@<domain> |
User, AuthSession |
your existing user and session models |
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://<domain>/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.)
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.
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.
POST /auth/forgot — always { ok: true }Rules, all required:
devCode (local dev and browser flows
have no inbox).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.
POST /auth/reset — one message for every failureEvery 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."
attempts, hashing, then writing back lets ten parallel guesses all read 0.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.
inputmode="numeric" autocomplete="one-time-code"), new password,
"Send a new code" link.devCode (the browser flow reads
it from there).Check: at 390 px wide nothing scrolls sideways.
Write a headless flow (see workflows/browser-flow-testing.md): sign up a fresh
flow-<timestamp>@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.
With a throwaway account on a +tag of your own inbox: request a code, confirm the
mail is in Inbox, from noreply@<domain>, signed-by: <domain>; complete the
reset; then delete the account.
Check: curl -s -X POST https://<domain>/api/auth/forgot -H 'content-type: application/json' -d '{"email":"[email protected]"}'
returns exactly {"ok":true} — no devCode.
| 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://<domain>/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 |
Accept: text/html)/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<domain>; throwaway account deleted