kitdocs · github

Password reset by emailed code

raw markdown workflows/password-reset.md verified 2026-09-26

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:

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
<app> the app
<domain> its domain; mail comes from noreply@<domain>
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://<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.)

2. Add the model — one row per user

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)

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:

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."

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

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-<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.

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@<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.

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://<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

Done when