Authentication and session management
In this series (15 parts)
- Backend system design scope
- Designing RESTful APIs
- Authentication and session management
- Database design for backend systems
- Caching in backend systems
- Background jobs and task queues
- File upload and storage
- Search integration
- Email and notification delivery
- Webhooks: design and security
- Payments integration
- Multi-tenancy patterns
- Backend for Frontend (BFF) pattern
- GraphQL server design
- gRPC and internal service APIs
Authentication answers one question: who is this user? Security in system design covers the broader picture, but here we focus on the backend mechanics: how tokens work, how sessions are managed, and how OAuth 2.0 fits together on the server side.
Session tokens vs JWTs
There are two dominant approaches to keeping a user logged in after they authenticate.
Session tokens
The server generates a random, opaque string (the session token), stores it in a database or cache keyed by the token, and sends it to the client as a cookie. On every request the client sends the cookie back, and the server looks up the session.
Advantages:
- Revocation is instant: delete the session record and the user is logged out.
- The token itself contains no user data, so leaking it reveals nothing about the user.
- Session data can grow over time without increasing token size.
Disadvantages:
- Every request requires a database or cache lookup.
- Scaling requires shared session storage across all application instances.
JWTs (JSON Web Tokens)
The server signs a JSON payload containing claims (user ID, roles, expiration) and sends it to the client. The client includes it in the Authorization header. The server verifies the signature without hitting a database.
Advantages:
- Stateless: no server-side storage needed for verification.
- Works naturally across multiple services; any service with the public key can verify the token.
Disadvantages:
- Cannot be revoked before expiration without maintaining a blocklist (which reintroduces state).
- Token size grows with claims. Large tokens increase request overhead.
- Sensitive data in the payload is only base64-encoded, not encrypted. Anyone can read it.
When to use which
Use session tokens when you need instant revocation (financial apps, admin panels). Use JWTs when you have multiple services that need to verify identity independently, and you can tolerate a short window of validity after revocation. Many production systems use both: short-lived JWTs for API access and server-side sessions for the refresh mechanism.
Refresh token rotation
Access tokens should be short-lived (5 to 15 minutes). When they expire, the client uses a refresh token to get a new access token without requiring the user to log in again.
Refresh token rotation works like this:
- The client sends the refresh token to the
/tokenendpoint. - The server validates it, issues a new access token and a new refresh token.
- The server invalidates the old refresh token.
If an attacker steals a refresh token and uses it, the legitimate client’s next refresh attempt will fail (because the token was already rotated). The server detects this and can invalidate the entire token family, forcing a re-login.
sequenceDiagram participant C as Client participant S as Auth Server participant DB as Token Store C->>S: POST /token (refresh_token_v1) S->>DB: Validate refresh_token_v1 DB-->>S: Valid, token family F1 S->>DB: Invalidate refresh_token_v1 S->>DB: Store refresh_token_v2 (family F1) S-->>C: access_token + refresh_token_v2 Note over C,S: Later, attacker uses stolen refresh_token_v1 C->>S: POST /token (refresh_token_v1) S->>DB: Validate refresh_token_v1 DB-->>S: Already used! Reuse detected S->>DB: Invalidate ALL tokens in family F1 S-->>C: 401 Unauthorized
Refresh token rotation with reuse detection. A stolen token triggers invalidation of the entire token family.
Stateless vs stateful auth
The choice between stateless (JWT-only) and stateful (server-side sessions) is really about where you store the session state:
| Aspect | Stateless (JWT) | Stateful (Session) |
|---|---|---|
| Storage | Client-side | Server-side (Redis/DB) |
| Verification cost | CPU (signature check) | I/O (cache/DB lookup) |
| Revocation | Delayed (until expiry) | Instant |
| Horizontal scaling | Easy | Requires shared store |
| Token size | Grows with claims | Fixed (opaque ID) |
Most production systems are hybrid. The access token is a short-lived JWT. The refresh token is a server-side record. This gives you stateless verification for 99% of requests and instant revocation when needed.
OAuth 2.0 authorization code flow with PKCE
OAuth 2.0 lets users grant third-party applications access to their data without sharing passwords. The authorization code flow with PKCE (Proof Key for Code Exchange) is the recommended flow for all clients, including public clients like mobile apps and SPAs.
sequenceDiagram participant U as User participant C as Client App participant AS as Auth Server participant RS as Resource Server C->>C: Generate code_verifier + code_challenge C->>AS: GET /authorize?response_type=code&code_challenge=...&redirect_uri=... AS->>U: Show login + consent screen U->>AS: Approve AS-->>C: Redirect with authorization_code C->>AS: POST /token (code + code_verifier + client_id) AS->>AS: Verify code_challenge matches hash(code_verifier) AS-->>C: access_token + refresh_token C->>RS: GET /api/resource (Bearer access_token) RS->>RS: Verify JWT signature RS-->>C: 200 OK + resource data
OAuth 2.0 authorization code flow with PKCE. The code verifier proves the token request comes from the same client that initiated the flow.
Server-side implementation details
On the auth server side, you need to:
- Generate the authorization code: a short-lived, single-use code tied to the client ID, redirect URI, and code challenge.
- Store the code: in a database or cache with a TTL of 60 to 300 seconds.
- Validate the token request: check the authorization code is valid, unused, and the
code_verifierhashes to the storedcode_challenge. - Issue tokens: generate the access token (JWT) and refresh token (opaque, stored server-side).
- Delete the authorization code: it is single-use.
PKCE prevents authorization code interception attacks. Without PKCE, an attacker who intercepts the code can exchange it for tokens. With PKCE, the attacker also needs the code_verifier, which never leaves the client.
Storing tokens securely
Where tokens live on the client matters as much as how they are generated.
Browser applications:
- Store access tokens in memory (JavaScript variable). They survive page navigation with SPAs but are lost on refresh, which is fine because you have refresh tokens.
- Store refresh tokens in
HttpOnly,Secure,SameSite=Strictcookies. JavaScript cannot read them, which protects against XSS. - Never store tokens in
localStorageorsessionStorage. These are accessible to any JavaScript on the page.
Mobile applications:
- Use the platform keychain (iOS Keychain, Android Keystore). These are hardware-backed secure storage.
- Never store tokens in shared preferences or plain files.
Server-to-server:
- Use client credentials flow. Store the client secret in a secrets manager (Vault, AWS Secrets Manager), not in environment variables or config files.
Multi-device sessions
Users log in from their phone, laptop, and tablet. Each device should have its own session. The server needs to:
- Track sessions per user: store a list of active sessions with metadata (device type, IP address, last active time).
- Allow selective revocation: the user can log out a specific device without affecting others.
- Enforce session limits: optionally cap the number of concurrent sessions (e.g., streaming services limiting to 3 screens).
CREATE TABLE user_sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
refresh_token_hash TEXT NOT NULL,
device_name TEXT,
ip_address INET,
last_active_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX idx_sessions_user ON user_sessions(user_id)
WHERE revoked_at IS NULL;
This partial index makes querying active sessions fast while keeping the table’s historical data for audit purposes.
Token lifecycle summary
graph LR
Login["User logs in"] --> Issue["Issue access + refresh tokens"]
Issue --> Use["Client uses access token"]
Use --> Expired{"Access token expired?"}
Expired -->|No| Use
Expired -->|Yes| Refresh["POST /token with refresh token"]
Refresh --> Rotate["Issue new tokens, invalidate old refresh"]
Rotate --> Use
Refresh -->|Invalid| Logout["Force re-login"]
style Login fill:#3498db,color:#fff
style Issue fill:#2ecc71,color:#fff
style Expired fill:#f39c12,color:#fff
style Logout fill:#e74c3c,color:#fff
The complete token lifecycle from login to refresh to forced re-login.
What comes next
The next article covers database design for backend systems: schema modeling, indexing strategies, query planning, and connection pooling. Your auth system stores sessions and tokens in a database, so understanding database design is the natural next step.