Identity: Sessions vs Bearer Tokens
Authentication systems must choose how to track user identity. Stateful sessions keep record on the server and use simple session keys in cookies, whereas stateless tokens (JWTs) carry the user's details and roles inside a signed payload.
Core details
| Parameter | Stateful Session Cookie | Stateless Bearer Token (JWT) |
|---|---|---|
| Server State | High (must lookup session ID in database/Redis on every request) | None (server validates signature cryptographically) |
| Revocation | Instant (delete session ID from backend store) | Delayed (until exp expires, or requires a blacklist database lookup) |
| XSS Exposure | Low (if protected by HttpOnly flag, JS cannot read cookie) | High (if stored in LocalStorage, scripts can read and exfiltrate it) |
| CSRF Exposure | High (cookies sent automatically; needs anti-CSRF tokens) | Low (bearer tokens must be set via JS headers, no auto-replay) |
[!IMPORTANT] If you store JWTs in LocalStorage, your app is vulnerable to token theft via XSS. For high-security SPAs, store tokens in memory, or use a BFF (Backend-for-Frontend) proxy pattern that translates backend cookies to downstream API bearer tokens.
Understanding
The Revocation Trade-off
Because stateless JWTs are self-contained and cryptographically verified, a service does not need to query a database to authorize a request. However, this means that if a user changes their password, is banned, or logs out, their issued JWT remains valid until its exp (expiration) timestamp.
To solve this, implement Short-Lived Access Tokens (e.g., 15 minutes) paired with Long-Lived Refresh Tokens (e.g., 7 days).
Refresh Token Rotation (RTR)
When the access token expires, the client submits the refresh token to get a new pair. If a refresh token is ever used more than once, the server assumes a leak occurred (the user and an attacker both have the token) and invalidates the entire family of tokens associated with that session.
Senior understanding
Refresh Token Rotation Middleware (Node.js concept)
Conceptual implementation of refresh token rotation and reuse detection:
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = 'access-key';
const REFRESH_SECRET = 'refresh-key';
// In-memory or Redis active refresh token hashes/families
const activeRefreshTokens = new Set<string>();
const invalidatedFamilies = new Set<string>();
interface TokenPayload {
userId: string;
familyId: string; // Tracks the token lineage
}
export async function rotateTokens(req: Request, res: Response) {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token required' });
}
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET) as TokenPayload;
const { userId, familyId } = decoded;
// 1. Reuse detection: if family is marked compromised, block everything
if (invalidatedFamilies.has(familyId)) {
return res.status(401).json({ error: 'Compromised session. Please re-authenticate.' });
}
// 2. If token is valid but NOT in active set, it is a reuse attempt!
if (!activeRefreshTokens.has(refreshToken)) {
invalidatedFamilies.add(familyId); // Invalidate the whole family
return res.status(401).json({ error: 'Token reuse detected. Session revoked.' });
}
// 3. Rotate tokens: consume old, issue new
activeRefreshTokens.delete(refreshToken);
const newAccessToken = jwt.sign({ userId }, ACCESS_SECRET, { expiresIn: '15m' });
const newRefreshToken = jwt.sign({ userId, familyId }, REFRESH_SECRET, { expiresIn: '7d' });
activeRefreshTokens.add(newRefreshToken);
res.json({
accessToken: newAccessToken,
refreshToken: newRefreshToken
});
} catch (err) {
res.status(401).json({ error: 'Invalid refresh token' });
}
}Diagram
See also
Mark this page when you finish learning it.
Spotted something unclear or wrong on this page?