Password Reset Flow Low-Level Design

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

Scroll to Top