AUTHTEMPLATE

Password Reset Flow

Password reset is the back door to every account in your app. An attacker who can't guess a password doesn't need to if the reset flow will hand them the account instead, and reset flows fail in quiet ways that demo perfectly. The form works, the email arrives, the password changes, everyone moves on. Meanwhile the token never expires, works twice, and the "no account with that email" error message is happily confirming to a stranger exactly which of your users exist.

Four properties separate a real reset flow from a liability, and this spec treats every one as a hard requirement. Tokens expire, in minutes, because reset emails sit in inboxes for years and an inbox is only as private as its weakest moment. Tokens are single-use, because a link that works twice works for whoever sees it second. The request endpoint answers identically whether the email exists or not, because "we couldn't find that account" is a user-enumeration oracle: it lets anyone test your user list one address at a time. And a successful reset kills every existing session, because the entire reason people reset passwords is the fear that someone else is already inside; a reset that leaves the intruder's session alive is theater.

If you're on Supabase or another managed auth provider, most of this is handled and your job is to not break it: the spec marks exactly which parts remain yours. If you're rolling the flow yourself, the token table and handler sketch are in here too. Either way, hand your agent the spec, then run the verification list personally. This is the one flow where "the agent says it works" is not good enough.

Prerequisites

  • Working email/password auth with a way to send email (managed provider, or SMTP/Resend/Postmark credentials in env vars).
markdown
# Spec: password reset flow

Add password reset to [app name]. Four hard requirements, none negotiable:
tokens expire in [30] minutes, tokens are single-use, the request endpoint
never reveals whether an email exists, and a successful reset invalidates
every existing session for that account.

## Stack

- Auth today: [Supabase Auth / custom, bcrypt or argon2 hashes in table X]
- Email delivery: [provider], credentials in env vars, never in code.

## The flow

1. `/forgot-password`: one email field. Submitting ALWAYS shows the same
   message: "If an account exists for that address, a reset link is on the
   way." Same response body, same status code, hit or miss.
2. If the account exists, email a link: `[APP_URL]/reset-password?token=...`
   Send nothing otherwise. The difference lives server-side only.
3. `/reset-password`: validates the token BEFORE showing the form. Expired,
   used, or unknown token gets "this link has expired, request a new one"
   and a link back to step 1. Never an editable form on a dead token.
4. On submit: re-validate token, enforce password rules [min 8 chars, or
   your policy], update the hash, mark the token used, kill all sessions,
   redirect to login with "password updated, sign in again."

## Managed provider path (Supabase)

Supabase handles token generation, expiry, single-use, and hashing. Still
on you:
- `resetPasswordForEmail(email, { redirectTo: APP_URL + "/reset-password" })`
  with `/reset-password` in the dashboard's redirect allowlist.
- The uniform response: Supabase errors on unknown emails; catch it and
  return the same message anyway. Don't forward the oracle to the browser.
- After `updateUser({ password })`, call `signOut({ scope: 'others' })` so
  every other device's session dies while the fresh one stands.
- Set token expiry to [30] minutes in Authentication settings; defaults
  are looser than you want.

## Roll-your-own path (skip if managed)

```sql
create table password_reset_tokens (
  token_hash text primary key,        -- sha-256 of the token; a DB leak
  user_id uuid not null references users(id) on delete cascade,
  expires_at timestamptz not null,    --   must not leak usable tokens
  used_at timestamptz                 -- null = unused
);
```

```typescript
// request handler: constant shape, no oracle
async function requestReset(email: string) {
  const user = await db.users.findByEmail(email);
  if (user) {
    const token = crypto.randomBytes(32).toString("base64url");
    await db.resetTokens.create({
      token_hash: sha256(token),
      user_id: user.id,
      expires_at: minutesFromNow(30),
    });
    await sendResetEmail(user.email, `${APP_URL}/reset-password?token=${token}`);
  }
  return { message: "If an account exists for that address, a reset link is on the way." };
}

// consume: single-use enforced atomically, not check-then-write
async function consumeToken(token: string) {
  const row = await db.resetTokens.update({
    where: { token_hash: sha256(token), used_at: null,
             expires_at: { gt: new Date() } },
    data: { used_at: new Date() },
  });
  return row?.user_id ?? null;   // null -> the generic expired-link page
}
```

- On success: write the new hash, then `delete from sessions where user_id = ?`
  (or bump a token-version column that every session check compares).
- Requesting a new token invalidates prior unused tokens for that user.
- Rate limit `/forgot-password` per email and per IP ([5/hour]): it sends
  email on your dime and probes your user list on theirs.

## Logging

Log requests and completions with user id and timestamp. Never log the
token, its hash, or the reset link. Logs outlive their access controls.

## Verification (run every one before calling this done)

1. Request a reset for a real account and a nonsense address: byte-identical
   responses, same status code.
2. Use the link once: works. Use it again: expired page.
3. Age a token past expiry (shrink expiry to 1 minute in dev): expired page.
4. Log in on two browsers, reset in one: the other's session is dead on its
   next request.
5. Request twice, click the FIRST email's link: expired page.

Adaptation notes:

  • Magic-link login is this same machinery with "set a new password" swapped for "create a session": same expiry, same single-use consumption, same enumeration rule on the request form. Build it as a variation, not from scratch.
  • Adding 2FA later: a reset must NOT bypass the second factor, or the reset email becomes the bypass. Require the code before the new password form for enrolled users.
  • Signup has the same oracle: "email already registered" enumerates users too. Same fix, uniform response plus an email that says "you already have an account" when one exists.
  • If sessions are stateless JWTs, there is no session row to delete; add a token_version column checked on every request and increment it on reset. If you can't invalidate a session, requirement four is unmet, and that's a redesign, not a footnote.
  • The mistake: testing only the happy path. The four requirements live entirely in the unhappy paths, which is why the verification list is the spec. An agent will report this flow done after one successful reset; the list is how you find out whether it's right.