Password Reset Flow — Low-Level Design
A password reset flow lets users regain account access via a verified email link. It must be secure against token brute-force and replay attacks, time-limited, and rate-limited. This design is asked at nearly every company — it is a fundamental authentication building block.
Core Data Model
PasswordResetToken
id BIGSERIAL PK
user_id BIGINT NOT NULL
token_hash TEXT NOT NULL -- SHA-256 of the actual token (never store plaintext)
expires_at TIMESTAMPTZ NOT NULL
used_at TIMESTAMPTZ -- null = unused
created_at TIMESTAMPTZ NOT NULL
ip_address INET -- for audit logging
CREATE INDEX idx_prt_user ON PasswordResetToken(user_id, created_at DESC);
-- Never index on token_hash — it reveals timing information
Initiating a Reset (Request Email)
import secrets, hashlib
def request_password_reset(email, requester_ip):
# Rate limit: max 5 requests per email per hour
if get_reset_request_count(email, window_hours=1) >= 5:
raise RateLimitExceeded('Too many reset requests')
# Lookup user (do not reveal whether email exists)
user = db.get_by(User, email=email.lower().strip())
if not user:
# Still return success response — don't leak email existence
return {'status': 'sent'}
# Generate cryptographically secure token
raw_token = secrets.token_urlsafe(32) # 256-bit entropy, URL-safe
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
# Invalidate any existing unused tokens for this user
db.execute("""
UPDATE PasswordResetToken
SET used_at=NOW()
WHERE user_id=%(uid)s AND used_at IS NULL AND expires_at > NOW()
""", {'uid': user.id})
db.insert(PasswordResetToken, {
'user_id': user.id,
'token_hash': token_hash,
'expires_at': now() + timedelta(hours=1),
'ip_address': requester_ip,
})
reset_link = f'https://app.example.com/reset-password?token={raw_token}'
send_password_reset_email(user.email, reset_link)
return {'status': 'sent'} # Always the same response
Validating and Consuming the Token
def reset_password(raw_token, new_password):
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
# Lookup the token record
token_record = db.execute("""
SELECT * FROM PasswordResetToken
WHERE token_hash=%(hash)s
AND used_at IS NULL
AND expires_at > NOW()
FOR UPDATE -- Prevent race condition (two simultaneous resets)
""", {'hash': token_hash}).first()
if not token_record:
raise InvalidToken('Token is invalid, expired, or already used')
# Validate new password strength
validate_password_strength(new_password)
# Hash new password
new_password_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt(rounds=12))
with db.transaction():
# Mark token as used
db.execute("UPDATE PasswordResetToken SET used_at=NOW() WHERE id=%(id)s",
{'id': token_record.id})
# Update password
db.execute("""
UPDATE User SET password_hash=%(hash)s, password_changed_at=NOW()
WHERE id=%(uid)s
""", {'hash': new_password_hash.decode(), 'uid': token_record.user_id})
# Invalidate all existing sessions (security: old sessions can't use old password)
db.execute("""
UPDATE Session SET revoked_at=NOW()
WHERE user_id=%(uid)s AND revoked_at IS NULL
""", {'uid': token_record.user_id})
return {'status': 'password_reset', 'user_id': token_record.user_id}
Security Properties Checklist
1. Never store the raw token — only its SHA-256 hash.
If the DB is compromised, attackers cannot use the hash to reset passwords.
2. Token entropy >= 128 bits.
secrets.token_urlsafe(32) provides 256 bits. A 6-digit code provides only 20 bits
— brute-forceable in seconds without rate limiting.
3. Expire tokens after 1 hour.
Short-lived tokens limit the attack window if a reset email is intercepted.
4. One-time use: mark used_at on consumption.
Replaying the same token URL must not reset the password again.
5. Invalidate old tokens on new request.
Prevents accumulation of valid tokens from multiple reset requests.
6. Revoke all sessions on password change.
Ensures an attacker who had an active session loses access after the reset.
7. Uniform response time (don't short-circuit for invalid emails).
Return the same response and take similar time whether or not the email exists.
8. Rate limit by IP and email.
Prevents enumeration of registered emails and token brute-force.
Key Interview Points
- Store the hash, not the token: The raw token in the URL is equivalent to a password. Store only SHA-256(token) in the database. This is the same principle as storing password hashes, not plaintext.
- Uniform response prevents email enumeration: Always return “if that email exists, a reset link has been sent.” Returning different responses for known vs unknown emails lets attackers enumerate registered users.
- Revoke sessions on reset: The whole point of password reset is to regain exclusive control of the account. An attacker who obtained a session token must lose access when the victim resets their password.
- FOR UPDATE prevents double-reset: Two simultaneous requests with the same token (possible via browser double-submit) must not both succeed. SELECT FOR UPDATE ensures only one transaction processes the token.
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”Why store the hash of the reset token instead of the token itself?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”The raw token in the reset URL is equivalent to a temporary password — anyone who obtains it can reset the account. If your database is breached and tokens are stored in plaintext, attackers can reset any account with an active token. Storing only SHA-256(token) means the database contains no usable values — even with full DB access, an attacker cannot reverse the hash back to a valid token. The same principle applies to API keys and session tokens. The raw token never touches persistent storage; only its hash is stored and compared.”}},{“@type”:”Question”,”name”:”How do you prevent email enumeration via the password reset form?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Always return the same response regardless of whether the submitted email is registered: "If an account exists with that email, a reset link has been sent." Never return "email not found" — this lets attackers enumerate registered users by submitting emails and observing different responses. Add artificial delay: sleep(random(0.5, 1.5) seconds) even for invalid emails to prevent timing-based enumeration (a response that returns immediately likely means no user was found). Apply this principle consistently across all authentication endpoints.”}},{“@type”:”Question”,”name”:”How do you rate limit password reset requests to prevent abuse?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Track reset attempts in Redis: INCR reset_attempts:{email} with TTL=3600. If count > 5: return 429 Too Many Requests without triggering another email send. Also rate limit by IP: INCR reset_attempts_ip:{ip} with TTL=3600, limit=20. The email limit prevents a targeted attack against one account (flooding someone’s inbox). The IP limit prevents bulk enumeration across many emails from one IP. Use exponential backoff for repeat offenders: after 5 attempts, require a 15-minute cooldown before accepting more.”}},{“@type”:”Question”,”name”:”How do you invalidate existing reset tokens when a new one is requested?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”When a new reset is requested, mark all existing unused tokens for this user as consumed: UPDATE PasswordResetToken SET used_at=NOW() WHERE user_id=%(uid)s AND used_at IS NULL. This prevents a scenario where an attacker captured an old token and tries to use it after the user unknowingly requested a new reset. Only one valid token should exist per user at any time. If the user receives a reset email they didn’t request, they should immediately request a new one, which invalidates the attacker’s captured token.”}},{“@type”:”Question”,”name”:”Why must all sessions be revoked after a successful password reset?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”A successful password reset means the account was compromised or the user lost access. In either case, any active sessions (which may be held by the attacker or on a lost device) must be terminated. UPDATE Session SET revoked_at=NOW() WHERE user_id=%(uid)s AND revoked_at IS NULL ensures the attacker’s session is immediately invalid. Without this step: an attacker who stole a session cookie retains access even after the victim resets their password. The user is given a fresh session after the reset so they can continue working, but all prior sessions are dead.”}}]}
Password reset and authentication flow design is discussed in Google system design interview questions.
Password reset and secure authentication design is covered in Stripe system design interview preparation.
Password reset and account security design is discussed in Coinbase system design interview guide.
See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering