Authentication

Designing a Password Reset Flow That Can't Be Abused

Password reset is the highest-value attack surface in most applications. A complete design — token handling, enumeration resistance, host header poisoning, session invalidation, and the failure modes that turn recovery into takeover.

Emilian GheoneaJuly 10, 20268 min read

Password reset is the most under-defended part of most authentication systems, and it is also the most valuable target. An attacker does not need to break your password hashing, guess a credential, or defeat your rate limiting if they can simply ask your application to hand them a new password. Every control you built on the login path is bypassed by a working reset flow.

The uncomfortable framing that makes this concrete: your password reset flow is an alternate login mechanism, and it is usually weaker than the primary one. Most teams apply MFA, rate limiting, and anomaly detection to sign-in, and then implement reset as a token in an email. If that is where you are, the effective strength of your authentication is the strength of the reset flow, not the login form.

This article walks the whole flow and the specific ways each stage fails.

The flow, stage by stage

Rendering diagram…

Five stages, and each one has a well-known way to get it wrong.

Stage 1: the request endpoint

Uniform responses, always. The response to "reset my password" must be identical whether the account exists or not: same body, same status, same timing, same redirect. "No account with that email" is a free account enumeration oracle, and enumeration is the input to every credential stuffing campaign — an attacker who can cheaply determine which of ten million leaked addresses have accounts on your platform has just made their attack ten times more efficient.

Timing is the part people miss. If the existing-account path sends an email and the non-existent path returns immediately, the response time difference is a reliable signal even with identical bodies. Do the work asynchronously — enqueue the send and return — so both paths take the same time regardless.

Rate limit on three keys. Per email address (an attacker should not be able to mailbomb a specific user), per IP (a single source should not probe thousands of addresses), and globally (a distributed campaign should trip a circuit breaker). The per-address cooldown is the highest-value one and the most commonly missing.

Beware the CAPTCHA-only defense. A CAPTCHA on the request form stops naive scripting and does nothing about a solver service costing fractions of a cent per solve. It is a layer, not the control.

Stage 2: the token

Everything about the reset token is security-critical because the token is the credential.

Entropy. 32 bytes from a cryptographically secure random source. Not a UUIDv4 embedded in a JWT, not base64(userId + timestamp), not a hash of the email plus a secret — the last of these has been the root cause of real, published account takeover vulnerabilities, because anyone who learns the construction can mint tokens for arbitrary addresses.

Store the hash, never the token. A database dump containing live reset tokens is a database dump containing working credentials for every account with a pending reset. Hash with SHA-256 on write, hash the presented value, compare in constant time. (Unlike passwords, a high-entropy random token does not need a slow KDF — there is nothing to brute-force.)

Short expiry. Fifteen minutes to one hour. This is genuinely different from email verification, where a 24-hour window is reasonable, because a reset token grants account access and a verification token does not. A user who lets the window lapse can request another one; that is a minor inconvenience, and the alternative is a live account-access credential sitting in an inbox for a day.

Single use, consumed atomically. Two concurrent submissions with the same token must not both succeed. Consume with a conditional update — UPDATE ... WHERE token_hash = ? AND used_at IS NULL RETURNING ... — rather than a read followed by a write.

Invalidate on issuance. Requesting a new reset should invalidate any outstanding ones. Otherwise an attacker who obtained an old token retains it after the user, suspecting something, requests a fresh one.

Invalidate on successful login. If the user remembers their password and signs in normally, any pending reset token should die. A token sitting live in a mailbox the user never checked is a liability with no remaining purpose.

Stage 3: the link and the host header trap

This is the vulnerability that has appeared in an extraordinary number of applications, and it is worth understanding precisely because it looks like nothing.

To build the reset URL, the application needs its own hostname. The tempting source is the incoming request's Host header:

// VULNERABLE — never do this
const resetUrl = `https://${req.headers.host}/reset?token=${token}`;

The Host header is attacker-controlled. An attacker submits a reset request for the victim's address with Host: attacker.com. Your server generates a link to https://attacker.com/reset?token=... and mails it, from your domain, to the victim. The victim clicks a link in a legitimate email from a service they use — and their reset token is delivered to the attacker's server in the request path.

Variants use X-Forwarded-Host, or exploit proxies that pass through a second Host header. The fix is the same in every case: build absolute URLs from configuration, never from the request.

// Correct — from a trusted, configured base
const resetUrl = new URL(`/reset?token=${token}`, process.env.APP_URL).toString();

While you are there, check the same thing for verification emails, invitation emails, and anything else that mails a link containing a credential. The pattern generalizes.

Two more details on the link itself. First, tokens in URLs land in Referer headers if the reset page loads any third-party resource — an analytics script, a font, an embedded widget — which leaks the token to that third party. Set Referrer-Policy: no-referrer on the reset page, and ideally strip the token from the URL with history.replaceState as soon as the page loads. Second, the same link-prefetch problem applies: a GET on the reset URL must only validate the token and render a form. It must never consume it.

Stage 4: setting the new password

Do not require the old password. The user is here because they do not have it. Some flows add this "for security" and simply make recovery impossible.

Do not send a new password by email. A password you generated and emailed is a plaintext credential permanently resident in a mailbox and in every mail server along the way. Send a link to set one.

Apply the same policy as signup. Length minimum (a real one — 12+ characters, no arbitrary maximum below 64, no character-class theatre), and a check against known-breached passwords using a k-anonymity range API so you never transmit the password or its full hash. There is no point in a careful reset flow that lets the user set Password123!.

Rehash with the current parameters. Reset is a natural point to upgrade a legacy hash. If the account is still on an old algorithm or a lower cost factor, this is where it gets fixed.

Do not reveal whether the new password matches the old one. "You cannot reuse your previous password" confirms to an attacker who reached this stage that a specific password was previously in use.

Stage 5: the aftermath, which is where teams stop too early

A reset that changes the password and nothing else is incomplete.

Revoke every session. All of them, on all devices, including refresh token families. The entire point of a reset in a compromise scenario is to evict the attacker; leaving their session alive because it predates the reset defeats the exercise. The one nuance worth considering is whether to spare the session that performed the reset — convenient, and defensible, since that session just proved mailbox access.

Notify the account. Send a "your password was changed" message to the address on file, including approximate time and location. This is the control that catches a takeover the user did not initiate. Include a clear, immediate action — a link that locks the account and starts recovery.

Do not silently disable MFA. A distressing number of flows reset the second factor along with the password "to avoid locking the user out." That collapses two independent factors into one: mailbox access alone now grants full account access, and the second factor has become decorative. MFA recovery must be its own flow with its own evidence — recovery codes generated at enrollment, or a separately-enrolled backup factor.

Write an audit event. Reset requested, reset completed, sessions revoked, notification sent — with IPs and user agents. When a user reports a takeover three weeks later, this is the only record that can reconstruct what happened. See audit logging for authentication.

The recovery paradox

The tension underneath all of this: a reset flow strong enough to resist an attacker is, by construction, strong enough to permanently lock out a legitimate user who has lost their mailbox. There is no design that escapes this. Every "helpful" softening — support staff who can reset on request, security questions, SMS fallback — is a parallel path an attacker can take, and social engineering the support desk is a well-established technique precisely because it works.

The honest answers are to be explicit about the tier of assurance each recovery path provides, to log and rate-limit the human-assisted ones as carefully as the automated ones, and to require multiple independent signals for high-value accounts rather than one. And, for the highest-assurance case, to accept that some accounts should be genuinely unrecoverable and say so at signup.

The structural fix is to reduce how much rides on the reset flow at all. Passkeys with multiple enrolled authenticators, or passwordless with a well-designed magic link, mean fewer users arrive at the reset flow to begin with.

Key takeaways

  • Reset is an alternate login path. Defend it at least as hard as the primary one.
  • Uniform responses and uniform timing, or you have shipped an enumeration oracle.
  • Tokens: high entropy, hashed at rest, short-lived, single-use, invalidated on new requests and on successful login.
  • Never build reset URLs from the Host header. Configuration only.
  • GET validates; POST consumes. Link scanners are real.
  • After a reset: revoke all sessions, notify the account, keep MFA intact, write the audit trail.

EmbedAuth's hosted reset flow implements this end to end — hashed single-use tokens with short expiry, uniform enumeration-safe responses, configured absolute URLs, full session revocation on completion, and an audit record for every step.

Written by

Emilian Gheonea

Senior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.