Account recovery and password reset done safely

'Forgot password' is one of the most attacked flows in most applications, precisely because it's designed to let someone in without their normal credential. Every step has a well-documented failure mode, and most of them still ship in production apps today.

Intermediate

3 min read

Why this flow is a bigger target than login itself

Login just checks a credential you already have. Password reset does something riskier by design: it grants access based on proving control of something else — usually an email inbox. That makes it a second, parallel authentication system, often built later, tested less, and reasoned about less carefully than the login flow it's meant to back up. Attackers know this, which is why account takeover attempts disproportionately target reset flows instead of brute-forcing passwords directly.

The token: unguessable, short-lived, single-use

The reset token is the entire security boundary of this flow — if it's guessable, nothing else matters.

import secrets
 
token = secrets.token_urlsafe(32)   # cryptographically random, not predictable
store_reset_token(user_id, token, expires_at=now() + timedelta(minutes=15))

Three properties are non-negotiable: the token must come from a cryptographically secure random source (not random.random() or an incrementing ID), it must expire quickly (15–30 minutes, not hours), and it must be deleted the instant it's used, so a reused or replayed link fails. A token that's still valid after the password has already been changed is a live vulnerability sitting in someone's inbox.

Don't leak whether an account exists

A reset form that responds differently for a registered vs. unregistered email is an account enumeration bug — it lets an attacker build a list of valid emails to target elsewhere (credential stuffing, phishing).

# WRONG
if not user_exists(email):
    return 404  # "No account found"
send_reset_email(email)
return 200
 
# CORRECT — identical response either way
if user_exists(email):
    send_reset_email(email)
return 200  # "If an account exists, we've sent a reset link"

The same discipline applies to response timing — if the "exists" path does a real database write and email send while the "doesn't exist" path returns instantly, the timing difference itself leaks the answer. Do the same amount of work either way.

The email-change trap

A well-documented real-world failure mode (Twitch's 2020 account-takeover reports being one example) is letting a single flow change both the account email and password at once, or letting a password reset silently succeed for an account with an unverified email. An attacker who signs up with someone else's email, without ever verifying it, can sometimes ride that unverified email straight into a password reset. Treat "change email" and "reset password" as separate operations, each requiring its own verification step, and never let a reset complete against an email address that hasn't been confirmed.

Notifying the real owner

The reset flow should assume it might be attacker-initiated and design for that: send a notification to the account's original email — not the new one, if an email change is involved — whenever a password is reset, with a "this wasn't me" link that lets the real owner revoke the change and lock the account. This turns a silent takeover into a noisy one the real user has a chance to catch within minutes, instead of finding out weeks later.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Why is the password reset flow often a bigger attack target than login itself?

2. Which property is NOT required of a secure password reset token?

3. What is account enumeration in the context of a 'forgot password' form?

4. Why is it risky to let one flow change both a user's email and password simultaneously?