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.
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.
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.
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.
Related
- Password Storage & Credential Hygiene — the parent guide covering hashing, screening and rotation
- Session Fixation Prevention — why the identifier must change at every privilege transition
- MFA Enforcement Patterns — enrolling and challenging the factor this flow requires
- Session Revocation and Logout Across Devices — the revocation mechanics this flow depends on