Password Reset Flows That Resist Account Takeover

Every control on the login path can be perfect and still be irrelevant, because attackers do not attack the strongest door. The reset flow is where careful password hashing, throttling and factor enrolment are most often bypassed: a link arrives, the link works, and possession of a mailbox becomes possession of an account.

This guide builds a reset that holds up: uniform responses that reveal nothing, single-use tokens bound to the current credential version, a factor challenge where one is enrolled, and full invalidation on completion. It is part of the Password Storage & Credential Hygiene guide within Secure Authentication & Session Architecture, and it depends on the rotation discipline in session fixation prevention.

Prerequisites

  • A transactional mail path you control, with delivery you can observe
  • A token store — the database or a key-value store with expiry
  • A credential version counter, or a value derived from the current password hash
  • An enrolled-factor lookup, and a distinct recovery path for a lost factor

Expected Outcomes

  • Identical responses and timings for known and unknown addresses
  • Tokens that are single-use, short-lived, stored only as digests, and version-bound
  • The enrolled second factor required before a reset completes
  • All sessions and refresh tokens revoked on completion, with the user notified

Step 1: Make the Request Endpoint Say Nothing

app.post('/auth/reset/request', rateLimit('auth.reset'), async (req, res) => {
  const email = normaliseEmail(req.body.email ?? '');
  const started = Date.now();

  const user = await users.findByEmail(email);          // may be null
  if (user) {
    const token = crypto.randomBytes(32).toString('base64url');
    await resetTokens.put({
      userHash: sha256(user.id),
      tokenHash: sha256(token),                          // only the digest is stored
      credentialVersion: user.credentialVersion,         // binds to the current password
      expiresAt: Date.now() + 30 * 60_000,
      usedAt: null,
    });
    await mail.sendResetLink(user.email, token);         // queued, not awaited inline
  }

  await padToConstantTime(started, 400);                 // same shape either way
  res.status(202).json({ status: 'if_the_address_exists_a_message_was_sent' });
});

The uniform answer is not a formality. An endpoint that returns quickly for unknown addresses and slowly for real ones is a user-enumeration API with a polite message attached, and enumeration is the first step of the campaign this control exists to stop.

Both Requests Must Look Identical From Outside For a known address the service creates a token, stores its digest and queues a message. For an unknown address it does none of that. Externally the status code, response body, elapsed time and rate-limit counters are the same, so an attacker learns nothing about which addresses have accounts. Address belongs to an account token generated, digest stored, mail queued Externally visible: 202 · same body · ~400 ms · counter +1 The work happens off the request path, so mail latency does not become a signal. Address is unknown no token, no store write, no message Externally visible: 202 · same body · ~400 ms · counter +1 Padding to a fixed floor is what keeps the two indistinguishable under measurement.

Step 2: Bind the Token to the Credential, Not Only to the Clock

An expiry alone leaves a window in which two tokens are simultaneously valid, which is how “I requested three resets and the first link still worked” incidents happen.

Four Conditions, All Required A reset token is accepted only when it has never been used, has not expired, matches the credential version recorded when it was issued, and can be marked used atomically. Dropping any one of these produces a specific failure: replay, a stale link, a superseded link that still works, or two parallel submissions both succeeding. Never used before without it: a link in a forwarded mail keeps working after the reset it performed Within its expiry window without it: a link sitting in a shared mailbox is a permanent account key Credential version still matches without it: requesting a second reset leaves the first link valid alongside it Marked used atomically — without it, two parallel submissions both succeed
export async function consumeResetToken(rawToken: string) {
  const record = await resetTokens.findByHash(sha256(rawToken));
  if (!record) throw new InvalidToken();

  const user = await users.byIdHash(record.userHash);
  const invalid =
    record.usedAt !== null ||                                  // single use
    record.expiresAt < Date.now() ||                           // time bound
    record.credentialVersion !== user.credentialVersion;        // superseded by any change

  if (invalid) throw new InvalidToken();

  await resetTokens.markUsed(record.id);                        // atomic compare-and-set
  return user;
}

The version check makes every previously issued token dead the moment the password changes or another reset is requested. Combined with an atomic mark-used, it also removes the race in which two parallel submissions of the same link both succeed.


Step 3: Require the Enrolled Factor, and Give Lost Factors Their Own Path

app.post('/auth/reset/complete', async (req, res) => {
  const user = await consumeResetToken(req.body.token);

  const factors = await mfa.enrolledFor(user.id);
  if (factors.length > 0 && !(await mfa.verify(user.id, req.body.factorResponse))) {
    return res.status(401).json({ error: 'factor_required' });   // mailbox alone is not enough
  }

  await passwords.set(user, req.body.password);       // screening + hashing happen inside
  await sessions.revokeAll(user.id);                  // including refresh tokens
  await resetTokens.invalidateAllFor(user.id);
  await notify.onAllChannels(user, 'password_changed'); // mail, and any verified phone or push

  res.status(204).end();
});

Users do genuinely lose their factors, and the answer is a separate, slower path rather than a quiet exemption: backup codes accepted here, or an identity-verification process that imposes a delay of a day, notifies every channel throughout, and is visible in the audit trail. The delay is the control — it gives a real owner time to object.


Step 4: Invalidate Everything and Say So

Revoking sessions on reset is what turns “I think someone is in my account” into a resolved situation.

await sessions.revokeAll(user.id);            // web sessions
await refreshTokens.revokeFamily(user.id);    // long-lived mobile and integration credentials
await apiKeys.flagForReview(user.id);         // personal access tokens, if the product has them

await notify.onAllChannels(user, {
  event: 'password_changed',
  signedOutDevices: revokedCount,
  reviewUrl: '/account/security/activity',
});

Notify on channels the attacker may not control, and include what actually happened — the number of devices signed out and a link to recent activity — because a notification the user cannot act on is decoration.

What Must Happen Before the Response Returns Five ordered actions on completion. The token is consumed atomically so it cannot be replayed. The enrolled factor is challenged. The new password is screened and hashed. Every session, refresh token and outstanding reset token is revoked. Finally the user is notified on every verified channel with the number of devices signed out. 1 · Consume the token atomically — single use, version-bound, unexpired 2 · Challenge the enrolled factor — mailbox access alone must not be sufficient 3 · Set the password — breach screening and memory-hard hashing apply here too 4 · Revoke every session, refresh token and outstanding reset token 5 · Notify on every verified channel, including how many devices were signed out

Verification

# 1. Known and unknown addresses answer identically.
for E in [email protected] nobody-$RANDOM@example.com; do
  curl -s -o /dev/null -w "$E %{http_code} %{time_total}\n" -X POST \
    http://localhost:3000/auth/reset/request -H 'Content-Type: application/json' \
    -d "{\"email\":\"$E\"}"
done
# Expected: identical status and closely matching times.

# 2. A token is single use.
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/auth/reset/complete \
  -d "{\"token\":\"$TOKEN\",\"password\":\"tram-vellum-quiet-harbour-92\"}" -H 'Content-Type: application/json'
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/auth/reset/complete \
  -d "{\"token\":\"$TOKEN\",\"password\":\"another-strong-passphrase-7\"}" -H 'Content-Type: application/json'
# Expected: 204 then 400.

# 3. Requesting a second reset invalidates the first link.
# 4. Sessions established before the reset must be dead afterwards.
curl -s -o /dev/null -w '%{http_code}\n' -H "Cookie: $OLD_SESSION" http://localhost:3000/api/me
# Expected: 401.

Troubleshooting

Symptom Likely cause Fix
Reset links stop working immediately Mail scanners prefetch the link and consume the token Require a POST from the reset page rather than completing on a GET
Old links keep working after a new request No credential-version binding Bind tokens to the version and bump it on every request and change
Users remain signed in on other devices Only the current session is revoked Revoke all sessions and refresh-token families on completion
Response times differ by address Work performed inline for known addresses only Queue the mail send and pad the response to a fixed floor
Support resets bypass the factor Operator tooling calls the internal path directly Route operator resets through the same policy, with an operator factor and an audit entry
Token values appear in server logs Token carried in the query string Deliver the token in the URL fragment or a POST body, and scrub log parameters

Common Implementation Mistakes


Frequently Asked Questions

Should a reset require the second factor?

Where one is enrolled, yes. A reset that needs only mailbox access reduces every account to the security of its email provider, which is precisely the assumption the second factor was added to remove. Provide a separate, deliberately slower path for a genuinely lost factor — backup codes, or identity verification with a delay and notifications throughout — rather than silently dropping the challenge when it is inconvenient.

How long should a reset token live?

Between fifteen and sixty minutes. That is long enough to survive slow mail delivery and a distracted user, and short enough that a link forwarded to a colleague, cached by a scanner, or sitting in a shared mailbox stops working quickly. Bind the token to the current credential version as well, so any subsequent password change or new reset request invalidates it immediately, independent of the clock.

Does resetting a password need to end existing sessions?

Yes. People reset passwords because they suspect compromise, and leaving existing sessions alive means an intruder keeps their access while the victim believes the problem is solved. Revoke every session and refresh-token family on completion, report how many devices were signed out, and give the user a link to review recent activity so they can spot anything else that needs attention.