A password reset flow gives password control to whoever holds a valid reset token. If issuance, consumption, or session cleanup is loose, the endpoint leaks account existence or keeps existing sessions valid after the password change. This post documents a safe default and its trade-offs.

System description

An API issues short-lived, single-use reset tokens via email. Submitting a valid token with a new password updates the password and revokes existing sessions. A separate email notifies the owner.

Architecture choice

There are two common implementations.

Stateful token in a server-side store

Each token is a random value stored server-side. The raw value ships only in the email; the hash lives in the database.

Use this when:

  • You want one-active-token-per-account as an invariant

  • Revocation must be cheap: flagging a token invalid should be one write

  • Forensic context per issued token (IP, user agent) matters

Main risks: the table is hot-path on consumption, so read and write latency matter. A leaked backup exposes token hashes, but not raw tokens.

Stateless signed token

The token is a signed blob carrying user_id, iat, and a nonce, verified with a service-wide secret. No database row per token.

Use this when:

  • Shared state isn't available and a table isn't an option

  • You want to trade revocability for simpler operations

Trade-off: you cannot invalidate a token before it expires without rotating the signing key, killing every issued token for every user. Enforcing "only one active reset token per account" still requires shared server-side state, which removes most of the reason to go stateless.

Default to stateful. You need single-token revocation without rotating a key that takes out every other user's token.

Golden path

Build this first. Then relax constraints only if you have a specific reason:

Client requests reset by email → API enqueues the request → Worker issues a token and emails the reset link → Client posts token with new password → API validates and atomically consumes → API updates password and revokes sessions → API notifies owner

Related patterns:

Core design

Token issuance

Tokens are 32 bytes from a CSPRNG, encoded with URL-safe base64 for the link. Store the SHA-256 hash of the raw token alongside these fields:

  • user_id (account binding)

  • token_hash (indexed, unique)

  • expires_at (creation + 15 minutes)

  • consumed_at (nullable, set on first use)

  • issued_ip and issued_ua (audit context)

Token consumption

The consume endpoint takes {token, new_password}. It:

  1. Validates the new password against policy (length, blocklist, HIBP check, reuse against the password history hash). On failure, returns a policy-specific error

  2. Computes SHA-256(token) and runs a single atomic conditional update: UPDATE password_reset_tokens SET consumed_at = NOW() WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > NOW() RETURNING user_id (or the equivalent for your database)

  3. If zero rows update, the token is already consumed, expired, or never existed; return a generic token error

  4. Writes the new password hash and revokes every session for the account (web cookies, mobile refresh tokens, API tokens, remember-device markers) in the same transaction

  5. Emits a password_changed event to the audit log and the notification mailer

Minimal API shape

POST /auth/password-reset           → 202 { status: "ok" } always
POST /auth/password-reset/confirm   → 204 on success, 400 on failure

The confirm endpoint always returns HTTP 400 on failure. Policy violations carry distinct body-level error codes (length, complexity, history, breach-corpus) so the UI can guide the user. Token-state failures (invalid, expired, already used) all look the same to the client.

Threat model

Baseline assumptions

  • The user's email account is semi-trusted. Inbox compromise and email forwarding rules are in scope; full end-to-end email confidentiality is not assumed

  • Attackers can make arbitrary authenticated and unauthenticated HTTP requests. They control their own user agent, network, and any cookies they can steal

  • The password store uses a modern memory-hard hash (argon2id or bcrypt with cost >= 12). Credential stuffing controls, MFA, and device posture are out of scope for this post; assume they're handled separately

  • The reset email is sent through a verified sender with SPF, DKIM, and DMARC alignment

  • Standard infra controls such as TLS, WAF, database AuthN, and logging hygiene are assumed to be in place. This model focuses on the state transitions of the reset flow itself

A note on risk: you won’t fix everything

This table isn’t a checklist where every row must be fully eliminated. Focus on preventing the worst failures and limiting blast radius. In practice: ship prevention for the High rows first, then add monitoring and response for what you can’t realistically prevent.

Phase 1: Request intake

Focus: Preventing the request endpoint from leaking account existence or amplifying abuse

Asset

Threat

Baseline Controls

Mitigation Options

Risk

User account

Enumeration via response parity, timing, or rate-limit behavior: Attacker submits emails and distinguishes accounts from response body, headers, latency, or per-email 429 timing

Generic 202 + async worker

1. Fixed body, status, Content-Length, and headers on every response, regardless of account existence

2. Do all user-lookup and rate-limit work on the async path; key limits on the raw submitted email

3. Keep the synchronous handler's timing identical across inputs

Low

Victim's inbox

Targeted email flood: Attacker triggers many resets to fill a victim's inbox or push legitimate emails out

No rate limits by default

1. Per-email cap (e.g., 5 per 15 min)

2. Consolidate repeated requests in a short window into one email ("we already sent you a reset link in the last 5 minutes")

3. Per-IP (e.g., 20 per 15 min) and global caps in front of per-email

Medium

Phase 2: Token lifecycle

Focus: Protecting the token between issuance and consumption

Asset

Threat

Baseline Controls

Mitigation Options

Risk

Reset token

Guessing: Attacker enumerates tokens by brute force against the consume endpoint

Unbounded guessing against a predictable token

1. CSPRNG-sourced token with at least 256 bits of entropy

2. Per-token attempt cap (e.g., 3 attempts before the token is invalidated)

3. Per-IP and global rate limits on the consume endpoint

Low

Token store

Storage exposure: DB backup or table dump leaks stored tokens

Raw token persisted

1. Store only SHA-256(token), never the raw value

2. Restrict direct access to the token table to the auth service

3. Alert on bulk reads of the table

Medium

Reset link

Email transit exposure: Link is captured from an insecure mail hop, browser history, or shared device

No TTL, long-lived link

1. Short TTL (e.g., 15 minutes)

2. Single-use consume: an atomic conditional update invalidates the token on the first success; superseding prior pending tokens on new issue closes the stale-link gap

3. Referrer-Policy: no-referrer on the landing page to stop Referer leaks to analytics and embedded assets

Medium

Reset link host

Host header poisoning: Attacker submits the reset request with a spoofed Host header; server embeds that host in the email link, so the victim clicks a trusted email that points at attacker-controlled infrastructure

Framework-default (link host from request)

1. Build reset URLs from a config value, never from req.headers.host

2. Allowlist-validate the host if multi-brand domains exist

3. Enforce https at link-construction time

Medium

Reset flow

Inbox takeover: Attacker with mailbox access triggers and consumes a reset without the owner's knowledge

None (silent reset by default)

1. Send a "password changed" email on every successful reset (including admin-initiated), including source IP, coarse geo, time, and a "this wasn't me" link that can lock the account, force email re-verification, and revoke lingering sessions

2. Require MFA re-verification on post-reset login for high-risk accounts

3. Log reset issuance, consumption, and revocation as distinct audit events

Medium

Reset token

Token reuse: Same token consumed twice (for example, two rapid clicks from the email client's link preview)

Non-atomic lookup-then-update

1. Atomic conditional update that sets consumed_at and returns success only on the first call

2. Second attempt returns the same generic error as an invalid token

3. Do not expose "already used" as a distinct error to the client

Medium

Phase 3: Consumption and post-reset state

Focus: Ensuring that the reset actually cleans up post-compromise state and cannot be used to escalate

Asset

Threat

Baseline Controls

Mitigation Options

Risk

Account sessions

Surviving session after reset: Attacker's existing session remains valid even though the password changed

Default session lifecycle

1. Invalidate every session for the user on password change

2. Revoke refresh tokens, mobile tokens, and "remember this device" markers

3. Revoke password-protected API tokens the user created

Medium

Account sessions

Session fixation through reset: Auto-login after reset reuses a session ID the attacker planted

Login flow may reuse any cookie the client sends

1. Issue a brand-new session cookie with a new session ID on post-reset login

2. Set HttpOnly, Secure, SameSite=Lax or stricter

3. Rotate CSRF tokens

Medium

MFA enrollment

MFA bypass via reset: Reset flow clears the user's 2FA enrollment, so an attacker with email access bypasses 2FA entirely

Framework defaults may clear MFA on reset

1. Do not unenroll MFA as part of reset

2. Require MFA challenge on the post-reset login even if the user just reset

3. If MFA is lost, route to a separate recovery flow with longer delays and out-of-band verification

Medium

If you use stateless tokens

If you ship signed tokens instead of a server-side store, the profile shifts:

  • Token revocation before the token expires is not possible without rotating the service signing key

  • The "at most one active reset token" invariant becomes unenforceable, so the email-flood and inbox-takeover rows become harder to constrain

  • Token reuse prevention requires a shared store anyway (a consumed-nonce set), so there's little left to gain by going stateless

  • Forensic context per issued token is limited to whatever is encoded in the token; no later annotations

Use stateless tokens only if the alternative is nothing (no state available at all).

FAQs

How long should a password reset token last?

Use a short lifetime, usually around 15 minutes, and make the token single-use. A longer lifetime increases the value of leaked email links, forwarded messages, browser history, and logs.

Should password reset tokens be stored in plaintext?

No. Store only a hash of the reset token server-side, and send the raw token only in the email link. If the database leaks, hashed reset tokens should not be directly usable to take over accounts.

Verification checklist

  • Response parity

    • POST /auth/password-reset returns the identical status, body, and headers for existing and non-existent emails

    • Response time for existing and non-existent emails is indistinguishable within normal jitter

    • Rate-limit responses do not reveal account existence (either identical to the baseline response, or the leak is documented and bounded)

  • Token entropy and storage

    • Tokens are generated from a CSPRNG with at least 256 bits of entropy

    • Only the SHA-256 hash of the token is stored; the raw token never appears in the database or logs

    • The token_hash column is unique and indexed

    • Only the auth service role has INSERT / UPDATE grants on the reset-token table (verifiable from an IAM / DB grants audit)

  • Token lifecycle

    • Tokens expire 15 minutes after issuance

    • Issuing a new token invalidates any prior pending token for the same account

    • Consumption is a single atomic conditional update; concurrent consumes of the same token result in at most one success

    • Expired and consumed tokens return the same generic error

  • Rate limiting

    • Per-email cap applies before any user lookup, keyed on the normalized submitted email string

    • Per-IP and global caps apply in front of per-email

    • Alerts fire on elevated reset request rates

  • Consumption and password update

    • New password is validated against length, complexity, history, and breach-corpus rules

    • Password hash uses argon2id or bcrypt with cost >= 12

    • Password update and session revocation run in the same transaction

    • A failed password-policy check leaves the token usable for the user's retry (policy validation runs before atomic consume)

  • Session hygiene

    • Every session for the account is deleted on successful reset (web cookies, mobile refresh tokens, API tokens, "remember this device" cookies)

    • Post-reset login issues a new session ID; no existing cookie is reused

    • CSRF tokens rotate on the new session

    • MFA enrollment is not cleared; MFA challenge is required on post-reset login

  • Notification and audit

    • A "password changed" email goes to the account owner on every successful reset

    • The notification includes time, source IP coarse geo, and a "this wasn't me" link that can lock the account

    • Reset issuance, consumption, and session revocation are logged as distinct events with correlation IDs

    • Recent reset attempts appear in the user's security dashboard

  • Email transport hygiene

    • Reset links use HTTPS only

    • The reset landing page sets Referrer-Policy: no-referrer

    • Reset emails are sent from a sender aligned with SPF, DKIM, and DMARC

    • Email templates do not include the raw token anywhere except the signed link

    • Reset link host comes from server config, not the incoming Host header; a request with a spoofed Host produces an email whose link still points at the configured domain

Implementation & Review

The full threat model matrix, architectural diagrams, and a printable verification checklist for this pattern are available in the Secure Patterns repository. Use these artifacts to guide your design reviews and internal audits.

Keep Reading