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 ownerRelated patterns:
If you are replacing passwords instead of resetting them, see Passkey Authentication: Architecting a Secure Relying Party for WebAuthn challenge handling, origin checks, and post-login session binding
If your reset endpoint triggers retries, email jobs, or other side effects, see Designing API Idempotency Keys to Prevent Duplicate Writes for the write-first pattern and safe background workers
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_ipandissued_ua(audit context)
Token consumption
The consume endpoint takes {token, new_password}. It:
Validates the new password against policy (length, blocklist, HIBP check, reuse against the password history hash). On failure, returns a policy-specific error
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)If zero rows update, the token is already consumed, expired, or never existed; return a generic token error
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
Emits a
password_changedevent 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 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. | Medium |
Reset link host | Host header poisoning: Attacker submits the reset request with a spoofed | Framework-default (link host from request) | 1. Build reset URLs from a config value, never from 2. Allowlist-validate the host if multi-brand domains exist 3. Enforce | 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 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 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-resetreturns the identical status, body, and headers for existing and non-existent emailsResponse 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_hashcolumn is unique and indexedOnly 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-referrerReset 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
Hostheader; a request with a spoofedHostproduces 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.
