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.
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