Representative interview topic

Backend Interview: Design a Secure Password Reset Flow

BackendHard
Offer.cc Editorial TeamPublished Updated

Question

Design a self-service password reset flow for a consumer web application. The user enters an email address and receives an HTTPS reset link that expires after 30 minutes. Prevent account enumeration, email flooding, token disclosure, replay, and races between concurrent reset attempts. The database must never store the raw reset token. Explain the APIs, data model, transaction boundary, password and session updates, MFA boundary, failures, observability, and security tests.

Prompt and Applicable Context

Design a self-service email-link password reset flow for a consumer web application. The request API accepts an email address and, when an account matches, sends an HTTPS link containing an opaque token. The token expires 30 minutes after creation. The database must never store the raw bearer token.

The design must prevent an unauthenticated caller from learning whether an email is registered, flooding one inbox, replaying a used link, changing another user's password, or winning a race with a second reset submission. A successful reset changes the password, invalidates every outstanding reset token for that account, revokes existing authenticated sessions, and emits a security notification. Password reset does not remove or replace an enrolled MFA authenticator.

This is primarily a backend security and consistency question. A polished answer connects the threat model to the API contract, token lifecycle, database transaction, email delivery, session model, and operational evidence. Naming a random token and an expiry is only the beginning; the difficult part is making every observable and concurrent path obey the same security contract.

What the Interviewer Evaluates

The first signal is threat modeling. The candidate should identify account enumeration, reset-email bombing, Host-header injection, URL leakage, token guessing, database disclosure, link replay, client tampering, concurrent consumption, stolen sessions, and MFA bypass. Each threat should map to a specific control rather than a generic claim that the endpoint is secure.

The second signal is the distinction between the raw token and its stored verifier. The email contains a high-entropy bearer secret. The database stores only a one-way digest, so reading the token table does not directly reveal a usable link. The candidate should also distinguish this random token from a human password: a user-chosen password needs a slow password hash, while a uniformly random 256-bit token can be indexed by a fast cryptographic digest without becoming guessable.

The third signal is transaction design. Checking used_at in application code and updating it later creates a replay race. Two different valid tokens for the same user create another race unless the account is the serialization point. A strong answer locks the user row, conditionally consumes the presented token, changes the password, revokes sibling tokens and sessions, and records an outbox event inside one short transaction.

The fourth signal is recovery-boundary judgment. Resetting a forgotten password is not permission to disable MFA or replace a lost second factor. Higher-assurance account recovery needs a separately designed proofing path. The answer should also reject security questions as a sole recovery mechanism and avoid sending either the old or a newly generated password by email.

The final signal is operational thinking. Generic responses are ineffective if timing, rate-limit behavior, logs, analytics, or support tooling still disclose account existence or raw tokens. Email delivery must survive provider failures without changing the account prematurely, and monitoring must detect abuse without storing the bearer secret in logs.

Questions to Clarify Before Answering

  • Is this password replacement or full account recovery? If the user still has a verified email

but lost only the password, the link can replace that password. Losing email access, MFA, and recovery codes requires a stronger, separate recovery process.

  • Does the account use MFA? A password reset should leave enrolled factors intact. If the product

wants one flow to recover both, its assurance requirements and abuse review change substantially.

  • Which sessions must be revoked? This prompt revokes all sessions. Keeping the initiating device

signed in would require proof that it was already trusted and a clearly documented exception.

  • May several links be outstanding? Allowing a small bounded number avoids invalidating a legitimate

email merely because a later message arrived first. Whichever link succeeds must revoke every other one for that user.

  • What risk and delivery channels apply? Email may be acceptable for a low-risk consumer account but

insufficient for financial or regulated access. SMS, recovery codes, support proofing, and waiting periods have different takeover and availability risks.

  • What password policy already governs sign-in? Reset must use the same length, blocklist, and hash

policy. A weaker reset-specific policy becomes an authentication bypass.

  • What abuse budget and email-provider limits exist? Rate limits need dimensions such as account,

IP, device, and global provider capacity. An account-only hard lock lets an attacker deny recovery to a known victim.

30-Second Answer Framework

“I would return the same 202 response and message for every email, then perform the lookup and delivery asynchronously behind layered rate limits. For a real account, I would generate 32 random bytes, email the base64url token in a link built from a configured HTTPS origin, and store only its digest with the user and a 30-minute expiry. The GET page never consumes the token. On the final POST, I revalidate it, hash the new password, lock the user row, atomically mark the token used, update the password, revoke all other reset tokens and sessions, and write notification and audit outbox events. Concurrent or replayed requests then fail the conditional consume. MFA recovery stays a separate flow.”

Step-by-Step Deep Dive

Start with two public endpoints and deliberately small response contracts:

text
POST /password-reset-requests
{ "email": "person@example.com" }

202 Accepted
{ "message": "If an account matches, reset instructions will be sent." }

POST /password-resets
{ "token": "opaque-base64url-value", "new_password": "..." }

The request endpoint returns the same status, message shape, and cache policy whether the account exists. It should hand work to a bounded queue before returning so an obvious database quick exit or SMTP call does not create a timing oracle. Do not add a fixed sleep and call the problem solved: queue saturation, account-only throttles, and different error paths can still expose behavior or become a denial-of-service tool.

Normalize the email only according to the product's verified identity rules. Lowercasing a domain may be valid; applying provider-specific rules such as removing dots or plus tags to every domain can join two distinct accounts. Layer rate limits across the normalized account key, source IP or network, device or risk signal, and the email provider's global budget. Escalate suspicious traffic to a challenge. The public response remains generic, and a forgotten-password request never locks the account or changes its password.

For an existing account, generate 32 bytes with a cryptographically secure random generator. That is 256 bits; unpadded base64url represents it in 43 characters. This is a design choice, not a universal expiry or encoding rule. Store SHA-256(raw_token) and send the raw value only through the email link. Because the input is uniformly random and high entropy, a fast one-way digest supports indexed lookup; using the digest itself as the submitted bearer value fails because the server hashes the input again. Passwords are low-entropy human secrets and therefore require a slow, salted password hash instead.

An illustrative PostgreSQL table is:

sql
CREATE TABLE password_reset_tokens (
  id uuid PRIMARY KEY,
  user_id uuid NOT NULL REFERENCES users(id),
  token_digest bytea NOT NULL UNIQUE,
  created_at timestamptz NOT NULL,
  expires_at timestamptz NOT NULL,
  used_at timestamptz,
  revoked_at timestamptz
);

CREATE INDEX password_reset_tokens_active_user_idx
  ON password_reset_tokens (user_id, expires_at)
  WHERE used_at IS NULL AND revoked_at IS NULL;

The email URL must use a configured or allowlisted origin rather than an untrusted Host header. Use HTTPS, redact the token from application, proxy, analytics, and error logs, and keep third-party assets off the reset page. Set a no-referrer policy so navigation does not disclose the query token. A GET may show the form or report that a link is invalid, but it must not consume the token: mail security scanners and link previews routinely visit links before the user does.

Do not trust a validation performed during that GET. A modified client can call the final endpoint directly, so the password-changing POST must receive and revalidate the token. Before opening a transaction, digest the token, find an unexpired candidate, validate the new password, and compute its slow password hash. This keeps the expensive password hashing work and most invalid requests outside the account lock. Rate-limit this endpoint as well.

The final state change uses the user row as the serialization point:

text
candidate = find token by SHA-256(raw_token)
reject publicly if candidate is missing, expired, used, or revoked
new_hash = Argon2id(new_password, fresh_salt, tuned_parameters)

BEGIN
  SELECT id FROM users WHERE id = candidate.user_id FOR UPDATE

  UPDATE password_reset_tokens
  SET used_at = now()
  WHERE id = candidate.id
    AND used_at IS NULL
    AND revoked_at IS NULL
    AND expires_at > now()
  RETURNING user_id

  if no row returned: ROLLBACK and reject

  UPDATE users
  SET password_hash = new_hash,
      password_changed_at = now(),
      auth_version = auth_version + 1
  WHERE id = candidate.user_id

  UPDATE password_reset_tokens
  SET revoked_at = now()
  WHERE user_id = candidate.user_id
    AND id <> candidate.id
    AND used_at IS NULL
    AND revoked_at IS NULL

  DELETE FROM sessions WHERE user_id = candidate.user_id

  INSERT security_outbox(password_reset_succeeded, user_id, occurred_at)
COMMIT

Compute the hash before BEGIN, but never commit it unless the conditional token update succeeds. For a new deployment, OWASP currently lists Argon2id with 19 MiB of memory, two iterations, and one degree of parallelism as one minimum configuration; benchmark and raise the cost while keeping legitimate authentication capacity safe. Store the algorithm and parameters with the password hash so they can evolve. Bcrypt is a legacy fallback with input-length constraints, not a drop-in synonym.

The row lock handles two race classes. If two requests present the same token, only the first conditional update can set used_at. If they present two different valid tokens for one user, both may pass the initial read and hash work, but only one holds the user lock. That transaction revokes the other token; after the second request acquires the lock, its conditional update returns no row. The password therefore has one winning value, and every losing request observes a terminal token state.

Server-side session deletion gives immediate invalidation for database-backed sessions. With refresh tokens, revoke their server records. For otherwise stateless access tokens, incrementing auth_version or checking password_changed_at works only if every protected request compares that server state; without such a check, revocation is delayed until token expiry. Make that limitation explicit rather than claiming that deleting a browser cookie revokes an attacker elsewhere.

Write the success notification and security audit event through an outbox in the same transaction, then deliver after commit. The notification contains time, account-recovery guidance, and a way to report fraud, never the old or new password. A notification-provider outage should trigger retries and alerts; it should not roll back a password that has already changed. On the request side, the token and email job should be durably related so a database commit followed by a queue failure does not silently strand the request. An outbox or one transactional job store solves that boundary.

Allow a bounded number of outstanding links instead of revoking the previous link on every request. Immediate replacement lets an attacker repeatedly request resets and invalidate the victim's email before it is opened. Limit outstanding rows, revoke or expire the oldest when the cap is reached, and revoke all remaining rows after success. Periodically delete terminal and expired rows according to the audit-retention policy.

Opaque stored tokens are usually simpler than self-contained JWT reset links. A signed JWT still needs server state for immediate one-time use, user-wide revocation, and password-change races; once that state exists, a random token plus digest has fewer claims and parsing paths. A short PIN is useful for manual entry but has much less entropy, so it needs strict attempt throttling and a limited reset session after verification.

Monitoring should track request rates by risk dimension, queue delay, provider errors, delivery outcomes, token verification failures, token age at success, replay attempts, and session-revocation failures. Keep raw emails, tokens, new passwords, and full reset URLs out of metrics and logs. Alerts should detect both global campaigns and one-account flooding without exposing account existence in the public response.

Test the flow as a state machine, not only as a happy-path endpoint. Send parallel POSTs with the same token and with two different tokens for one user; exactly one may succeed. Verify expiry at the boundary, reuse after success, sibling-token revocation, nonexistent accounts, queue and email outages, Host-header manipulation, Referer leakage, log redaction, rate-limit dimensions, session rejection on another device, notification retries, and an account with MFA. Security scanners should be able to GET the link without consuming it.

High-Quality Sample Answer

“I would define the reset token as a temporary authenticator and design around its entire lifecycle. The request endpoint always returns the same 202 response. It queues the lookup and delivery behind account, network, device, and provider-level abuse controls, but it never locks or changes the account just because someone typed an email address.

For a matching account, I generate 32 random bytes, send the base64url value in an HTTPS link, and store only its SHA-256 digest, user ID, and 30-minute expiry. The reset origin is configured rather than derived from Host; the page has no third-party resources, uses a no-referrer policy, and all log pipelines redact the token. GET only displays the form because an email scanner may open the link.

The final POST validates the token again and hashes the new password before starting a short transaction. Inside the transaction I lock the user row, conditionally mark that token used, update the password and authentication version, revoke every sibling reset token and server-side session, and insert an outbox event. The conditional update defeats same-token replay; the user lock plus sibling revocation makes two different tokens serialize to one winner.

After commit, workers send the security notification and retry independently. I would test generic responses and timing, token and URL leakage, concurrent use, expiry, provider failure, remote-session rejection, and MFA accounts. Resetting the password leaves MFA intact; losing all authenticators goes through a separate higher-assurance recovery process.”

Common Mistakes

  • Returning “email not found” → an attacker can enumerate registered accounts → **return the same

public status and message and avoid a quick-exit timing path.**

  • Storing the raw token → a database reader gains working reset links → **store a one-way digest and

redact the raw value everywhere except delivery and user submission.**

  • Using a user ID from the final form → a client can swap the target account → **derive the user only

from the server-side token record.**

  • Consuming the link on GET → an email scanner can invalidate it before the user arrives → **consume

only during the password-changing POST.**

  • Validating on GET but not POST → a forged client bypasses the displayed form → **revalidate expiry

and terminal state in the final server-side transaction.**

  • Checking then updating without a condition → parallel requests can both pass the check → **use a

conditional update inside a transaction and serialize different tokens on the user row.**

  • Invalidating the previous link on every request → an attacker can keep the victim's newest email

stale → allow a bounded set and revoke all siblings only after one succeeds.

  • Building the URL from Host a poisoned request can send an attacker-controlled reset domain →

use a configured or allowlisted HTTPS origin.

  • Putting tokens in logs or analytics → observability systems become credential stores → **redact

query strings and prohibit third-party resources on the reset page.**

  • Auto-login after reset without a session policy → session fixation and stolen-session behavior

become unclear → require normal login and explicitly revoke server-side sessions.

  • Resetting MFA with the password → control of one email can defeat a stronger factor → **keep MFA

recovery as a separate risk-based flow.**

  • Sending a new password by email → the password persists in an insecure channel and an attacker can

lock out the victim → send a time-limited link and change nothing until valid proof is submitted.

Follow-Up Questions and Responses

Follow-up 1: What changes if the product uses stateless JWT access tokens?

Revoke refresh tokens at the server. For immediate access-token invalidation, include an authentication version or issued-at value and compare it with current server state on every protected request. If the architecture refuses that lookup or a revocation cache, old access tokens remain valid until expiry; state the maximum exposure instead of promising immediate logout.

Follow-up 2: Should a new request invalidate every previous reset link?

Usually not before success. Email can be delayed or reordered, and an attacker who knows the address could continually invalidate the victim's latest link. Keep a small bounded set of unexpired tokens, control request volume, and revoke every sibling in the successful password-change transaction. A high-risk product may choose stricter replacement, but it must accept that availability trade-off.

Follow-up 3: How would you support a six-digit code instead of a URL token?

A six-digit code has far less entropy than a 256-bit token. Bind it to the account and a narrowly scoped reset attempt, impose server-side attempt throttling and expiry, and create a short-lived reset session only after successful verification. Do not let a verified code become a general authenticated session or allow the client to choose the user ID.

Follow-up 4: Which new-password rules would you apply?

Use the same verifier policy as sign-in. If the product adopts current NIST guidance, a password used as a single factor has a 15-character minimum; one used only within MFA may have an 8-character minimum. Permit at least 64 characters, reject common or compromised values with a blocklist, and do not add arbitrary composition or periodic-rotation rules. Tune the password-hash cost separately from these input rules.

Follow-up 5: What if the user also lost access to email and MFA?

That is full account recovery, not this password-reset endpoint. Use pre-enrolled recovery codes or contacts, remaining authenticators, repeated identity proofing, waiting periods, and manual review according to account risk. Changes to recovery addresses themselves need verification and independent notifications. Never fall back to easily researched security questions as the sole proof.

Follow-up 6: How do you verify that account enumeration is actually controlled?

Compare existing and nonexistent accounts across status, body, headers, cache behavior, latency distributions, rate-limit transitions, and downstream side effects. Test under queue saturation and provider failure, not only a quiet local run. Review CDN, proxy, application, analytics, and support logs for identifiers and full URLs, and confirm that abuse dashboards expose aggregate signals without becoming a lookup service for account existence.

Public sources

Related questions