Authentication is one of the most common low-level design questions in system design interviews, and one of the most commonly implemented incorrectly in production. This guide covers the full stack: password storage, token lifecycle, session management, OAuth2, and MFA, with schema and pseudocode for each component.
Password Storage
Never store plaintext passwords. Never store MD5 or SHA256 hashes – they are fast, which makes them vulnerable to brute force and rainbow table attacks. Use a slow, purpose-built password hashing function.
bcrypt
bcrypt is the industry standard. It incorporates a salt automatically and has a tunable cost factor that increases computation time.
import bcrypt
# Hashing at registration
def hash_password(plaintext: str) -> bytes:
cost = 12 # 2^12 = 4096 rounds, roughly 250ms on modern hardware
salt = bcrypt.gensalt(rounds=cost)
return bcrypt.hashpw(plaintext.encode(), salt)
# Verification at login
def verify_password(plaintext: str, hashed: bytes) -> bool:
return bcrypt.checkpw(plaintext.encode(), hashed)
Cost factor 12 is a reasonable baseline in 2024. As hardware gets faster, increase it to keep verification time around 100-300ms. bcrypt stores the salt inside the hash string, so you only store one field.
Argon2 is the modern alternative, winner of the Password Hashing Competition (2015). It is memory-hard, making GPU attacks more expensive. Use argon2-cffi in Python. Prefer Argon2id which resists both side-channel and GPU attacks.
JWT Access Tokens
JSON Web Tokens (JWTs) allow stateless authentication. The server signs a token at login; subsequent requests carry the token, and the server verifies the signature without a database lookup.
Structure
A JWT is three base64url-encoded parts joined by dots: header.payload.signature.
// Header
{"alg": "HS256", "typ": "JWT"}
// Payload (claims)
{
"sub": "user_id_123",
"email": "user@example.com",
"roles": ["user"],
"iat": 1700000000,
"exp": 1700000900 // 15 minutes from iat
}
// Signature
HMAC-SHA256(base64url(header) + "." + base64url(payload), secret_key)
Key rules:
- Keep TTL short – 15 minutes is standard. Short TTL limits exposure if a token is stolen.
- Do NOT store sensitive data in the payload – it is only base64-encoded, not encrypted.
- Use RS256 (asymmetric) in multi-service architectures so services can verify without knowing the signing secret.
- Validate
exp,iat, andisson every request.
Refresh Token Rotation
Short-lived access tokens require a mechanism to get new ones without re-login. Refresh tokens are long-lived credentials stored server-side that can issue new access tokens.
Flow
Login:
-> server issues access_token (15 min) + refresh_token (30 days)
-> refresh_token stored in DB (hashed), sent to client as HttpOnly cookie
Access token expired:
-> client sends refresh_token
-> server validates: hash(token) exists in DB, not revoked, not expired
-> server issues new access_token + new refresh_token (rotation)
-> old refresh_token is invalidated in DB
Reuse detection:
-> if a refresh_token is used that was already rotated (appears used),
the entire token FAMILY is invalidated (all sessions for that user/device)
-> this detects token theft: if attacker replays a stolen old token,
the legitimate user's next request triggers family invalidation
Session Table Schema
CREATE TABLE sessions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL REFERENCES users(id),
refresh_token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA256 of token
family_id UUID NOT NULL, -- for reuse detection
device_info VARCHAR(255), -- user-agent, device type
ip_address VARCHAR(45),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
revoked_at TIMESTAMP
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_family ON sessions(family_id);
Store only a hash of the refresh token (SHA256 is fine here – you are not defending against brute force, just storing a reference). The raw token goes to the client; the server never stores it.
OAuth2 Authorization Code Flow
OAuth2 is used when your app allows users to log in via a third-party identity provider (Google, GitHub, etc.). The Authorization Code flow is the secure choice for server-side applications.
Step 1: Redirect to provider
GET https://accounts.google.com/o/oauth2/auth
?client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&response_type=code
&scope=openid email profile
&state=RANDOM_CSRF_TOKEN // store in session, verify on return
&code_challenge=CODE_VERIFIER_HASH // PKCE
&code_challenge_method=S256
Step 2: User authenticates at provider, consents to scopes
Step 3: Provider redirects back
GET https://yourapp.com/callback?code=AUTH_CODE&state=RANDOM_CSRF_TOKEN
Step 4: Verify state, exchange code for tokens
POST https://accounts.google.com/o/oauth2/token
code=AUTH_CODE
client_id=YOUR_CLIENT_ID
client_secret=YOUR_CLIENT_SECRET
redirect_uri=https://yourapp.com/callback
grant_type=authorization_code
code_verifier=ORIGINAL_CODE_VERIFIER // PKCE
Step 5: Provider returns access_token, id_token (JWT), refresh_token
Step 6: Extract user info from id_token or call userinfo endpoint,
create/update local user record, issue your own session
PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. Always use it even for server-side apps.
Multi-Factor Authentication (TOTP)
Time-based One-Time Passwords (TOTP) use a shared secret and the current time to generate 6-digit codes that change every 30 seconds.
Algorithm
import hmac, hashlib, base64, time, struct
def totp(secret_base32: str, digits: int = 6, interval: int = 30) -> str:
key = base64.b32decode(secret_base32.upper())
# T = number of 30-second intervals since Unix epoch
T = int(time.time()) // interval
msg = struct.pack('>Q', T) # 8-byte big-endian
h = hmac.new(key, msg, hashlib.sha1).digest()
# Dynamic truncation
offset = h[-1] & 0x0F
code = struct.unpack('>I', h[offset:offset+4])[0] & 0x7FFFFFFF
return str(code % (10 ** digits)).zfill(digits)
The server generates the secret, displays it as a QR code (URI format), and the user scans it with an authenticator app. At verification, the server computes TOTP for T-1, T, and T+1 to allow for clock skew.
Backup Codes
Generate 10 random backup codes at MFA setup. Store them hashed with bcrypt (same as passwords). Each code is single-use – mark it as consumed after use.
MFA Config Schema
CREATE TABLE mfa_configs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL UNIQUE REFERENCES users(id),
type ENUM('totp', 'sms') NOT NULL DEFAULT 'totp',
secret VARCHAR(64) NOT NULL, -- encrypted at rest
verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE mfa_backup_codes (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL REFERENCES users(id),
code_hash VARCHAR(60) NOT NULL, -- bcrypt hash
used BOOLEAN NOT NULL DEFAULT FALSE,
used_at TIMESTAMP
);
Rate Limiting Login Attempts
Use Redis for fast, atomic counters. Key by both IP and username to prevent distributed attacks.
import redis
import time
r = redis.Redis()
def check_rate_limit(ip: str, username: str) -> tuple[bool, int]:
"""Returns (allowed, wait_seconds)"""
ip_key = f"login_fail:ip:{ip}"
user_key = f"login_fail:user:{username}"
ip_fails = int(r.get(ip_key) or 0)
user_fails = int(r.get(user_key) or 0)
fails = max(ip_fails, user_fails)
if fails >= 10:
ttl = r.ttl(user_key)
return False, max(ttl, 0)
return True, 0
def record_failure(ip: str, username: str):
ip_key = f"login_fail:ip:{ip}"
user_key = f"login_fail:user:{username}"
# Exponential backoff: 2^fails seconds, capped at 1 hour
fails = int(r.incr(ip_key))
r.incr(user_key)
expiry = min(2 ** fails, 3600)
r.expire(ip_key, expiry)
r.expire(user_key, expiry)
def record_success(ip: str, username: str):
r.delete(f"login_fail:ip:{ip}")
r.delete(f"login_fail:user:{username}")
Full Database Schema
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(60), -- NULL for OAuth-only accounts
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW() ON UPDATE NOW(),
deleted_at TIMESTAMP -- soft delete
);
CREATE TABLE oauth_providers (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL REFERENCES users(id),
provider VARCHAR(32) NOT NULL, -- 'google', 'github', etc.
provider_user_id VARCHAR(255) NOT NULL,
access_token TEXT, -- encrypted
refresh_token TEXT, -- encrypted
expires_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (provider, provider_user_id)
);
Token Revocation at Scale
The fundamental tension: JWTs are stateless (no DB lookup) but cannot be revoked before expiry.
Strategy 1 – Short TTL + refresh revocation (recommended):
Keep access token TTL at 15 minutes. When a user logs out or a compromise is detected, revoke the refresh token in the sessions table. The access token remains valid for up to 15 minutes, which is an acceptable tradeoff for most applications.
Strategy 2 – Token blocklist in Redis:
On logout, add the JWT’s jti (unique token ID) to a Redis set with the same TTL as the token. Every request checks the blocklist. This is exact revocation but adds a Redis lookup to every request.
Strategy 3 – Opaque tokens:
Use random strings as tokens. Every request hits the database to look up the session. Simpler logic, perfect revocation, but adds DB latency to every authenticated request. Acceptable for lower-scale applications.
For most production systems: Strategy 1 for access tokens + database revocation for refresh tokens is the right balance.
Security Best Practices
- HTTPS only: Set HSTS headers. Never transmit tokens over HTTP.
- HttpOnly + SameSite=Strict cookies: Store refresh tokens in HttpOnly cookies to prevent XSS access. SameSite=Strict prevents CSRF.
- Separate token storage: Access token in memory (JavaScript variable), refresh token in HttpOnly cookie. This limits XSS to 15-minute access token exposure.
- PKCE for OAuth: Always use PKCE even for confidential clients. It prevents code interception in proxies.
- Rotate signing secrets: Support multiple valid signing keys with a key ID (kid) in the JWT header to allow seamless rotation.
- Log authentication events: Login success/failure, MFA events, and token issuance. Ship to a SIEM. Rate-limit log volume per user.
- Account lockout vs. progressive delays: Hard lockout enables denial-of-service against users. Progressive delays (exponential backoff) are usually preferable.
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How should passwords be stored securely?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use bcrypt with a cost factor of 12 (or Argon2id for new systems). bcrypt automatically generates and embeds a unique salt per password, preventing rainbow table attacks. It is deliberately slow: cost factor 12 takes ~250ms to hash, making brute force impractical. Never store passwords as plaintext or reversible encryption. Never use fast hashes (MD5, SHA-1, SHA-256) alone – they are too fast (billions of hashes/second on GPU). The stored value looks like: $2b$12$<22-char-salt><31-char-hash>. On login, bcrypt extracts the salt from the stored hash and rehashes the input for comparison. Argon2id is the OWASP recommended choice for new systems as it is resistant to both GPU and side-channel attacks.”}},{“@type”:”Question”,”name”:”What is the difference between access tokens and refresh tokens?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Access tokens are short-lived JWTs (15 minute TTL) used to authenticate API requests. They are verified without a database lookup (stateless), making them fast but impossible to instantly revoke. Refresh tokens are long-lived (7-30 days), stored securely (httpOnly cookie or secure storage), and used to obtain new access tokens. They are single-use with rotation: each use issues a new refresh token and invalidates the old one. If a refresh token is reused (attacker replays a stolen token), the system detects a family conflict and invalidates all refresh tokens in that session family. This bounds the window of compromise: even if an access token is stolen, it expires in 15 minutes; refresh token theft is detectable via reuse.”}},{“@type”:”Question”,”name”:”How does the OAuth2 authorization code flow work?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Six steps: (1) Client redirects user to the authorization server with client_id, redirect_uri, scope, state, and code_challenge (PKCE). (2) User authenticates and consents. (3) Authorization server redirects back to the client with an authorization code (short-lived, one-time use). (4) Client exchanges the code for tokens by sending code + code_verifier to the token endpoint (server-to-server call, never exposed to browser). (5) Authorization server returns access_token (short-lived) and refresh_token. (6) Client uses access_token in Authorization header for API calls. PKCE (code_verifier / code_challenge) prevents authorization code interception attacks. State parameter prevents CSRF.”}},{“@type”:”Question”,”name”:”How does TOTP (Time-based One-Time Password) work for MFA?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”TOTP generates a 6-digit code that changes every 30 seconds. Algorithm: code = HOTP(secret, floor(unix_timestamp / 30)). HOTP = HMAC-SHA1(secret, counter), then extract a 6-digit number from the HMAC digest. The secret is shared once (via QR code during setup) and stored server-side (encrypted). On verification: compute TOTP for the current time window and the adjacent windows (to allow for clock skew). Accept if the user-provided code matches any. Backup codes: generate 8-10 random codes at setup, store as bcrypt hashes. Each can only be used once (mark as used on consumption). Never store backup codes in plaintext.”}},{“@type”:”Question”,”name”:”How do you handle session revocation at scale?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Two main approaches: (1) Short-lived JWTs (15 min TTL) with refresh token invalidation. Revoke by invalidating the refresh token in the DB. The access token remains valid until expiry – acceptable for most use cases. (2) Token blocklist: maintain a Redis set of revoked JWT IDs (jti claim). On each request, check if the jti is in the blocklist. TTL of blocklist entries = JWT expiry. This enables instant revocation at the cost of a Redis lookup per request. For high-security contexts (payment APIs, admin actions): use short-lived tokens and a blocklist. For typical web apps: short-lived JWTs without a blocklist is sufficient and avoids the Redis dependency on the hot path.”}}]}
Stripe system design interviews cover authentication and token management. See design patterns for Stripe interview: authentication and token system design.
Coinbase system design covers security-critical authentication systems. See patterns for Coinbase interview: authentication and security system design.
Shopify system design covers OAuth and multi-tenant authentication. See design patterns for Shopify interview: OAuth and authentication system design.