Deep-dive reference for JSON Web Tokens, session-based authentication, cookie security, and CSRF protection.
A JWT consists of three Base64URL-encoded parts separated by dots.
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-2024-01"
}
| Field | Purpose |
|---|---|
alg |
Signing algorithm (RS256, ES256, HS256) |
typ |
Token type (always "JWT") |
kid |
Key ID for key rotation (optional but recommended) |
{
"iss": "https://auth.example.com",
"sub": "user_abc123",
"aud": "https://api.example.com",
"exp": 1700001500,
"nbf": 1700000600,
"iat": 1700000600,
"jti": "unique-token-id-xyz"
}
| Claim | Required | Purpose |
|---|---|---|
iss |
Recommended | Identifies the token issuer |
sub |
Recommended | Identifies the subject (user ID) |
aud |
Recommended | Intended recipient(s) of the token |
exp |
Required | Expiration time (Unix timestamp) |
nbf |
Optional | Token not valid before this time |
iat |
Recommended | Time the token was issued |
jti |
Optional | Unique identifier for the token (for revocation) |
{
"role": "admin",
"permissions": ["read", "write", "delete"],
"org_id": "org_456",
"tenant": "acme-corp"
}
Guidelines for custom claims:
https://example.com/roleRSASHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
privateKey
)
The signature ensures the token has not been tampered with. Verification uses the public key (asymmetric) or shared secret (symmetric).
Type: Asymmetric (public/private key pair) Key size: 2048 bits minimum (4096 recommended) Use when: Multiple services verify tokens, auth server is separate from resource servers.
// Node.js (jose library)
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose';
// Sign (auth server - has private key)
const privateKey = await importPKCS8(privateKeyPem, 'RS256');
const token = await new SignJWT({ sub: 'user_123', role: 'admin' })
.setProtectedHeader({ alg: 'RS256', kid: 'key-2024-01' })
.setIssuedAt()
.setIssuer('https://auth.example.com')
.setAudience('https://api.example.com')
.setExpirationTime('15m')
.sign(privateKey);
// Verify (resource server - has public key only)
const publicKey = await importSPKI(publicKeyPem, 'RS256');
const { payload } = await jwtVerify(token, publicKey, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
# Python (PyJWT)
import jwt
from datetime import datetime, timedelta, timezone
# Sign
token = jwt.encode(
{
"sub": "user_123",
"role": "admin",
"iss": "https://auth.example.com",
"aud": "https://api.example.com",
"exp": datetime.now(timezone.utc) + timedelta(minutes=15),
"iat": datetime.now(timezone.utc),
},
private_key,
algorithm="RS256",
headers={"kid": "key-2024-01"},
)
# Verify
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"],
issuer="https://auth.example.com",
audience="https://api.example.com",
)
// Go (golang-jwt/jwt/v5)
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
// Sign
claims := jwt.MapClaims{
"sub": "user_123",
"role": "admin",
"iss": "https://auth.example.com",
"aud": "https://api.example.com",
"exp": time.Now().Add(15 * time.Minute).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = "key-2024-01"
signedToken, err := token.SignedString(privateKey)
// Verify
parsedToken, err := jwt.Parse(signedToken, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return publicKey, nil
}, jwt.WithIssuer("https://auth.example.com"),
jwt.WithAudience("https://api.example.com"))
Type: Asymmetric (public/private key pair) Curve: P-256 Use when: Same as RS256 but smaller tokens and faster signing. Preferred for new systems.
// Node.js (jose)
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose';
const privateKey = await importPKCS8(ecPrivateKeyPem, 'ES256');
const token = await new SignJWT({ sub: 'user_123' })
.setProtectedHeader({ alg: 'ES256' })
.setExpirationTime('15m')
.sign(privateKey);
ES256 vs RS256:
Type: Symmetric (shared secret) Use when: Single service creates and verifies tokens. Simple internal use.
// Node.js (jose)
import { SignJWT, jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
// Secret must be at least 256 bits (32 bytes) for HS256
const token = await new SignJWT({ sub: 'user_123' })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('15m')
.sign(secret);
const { payload } = await jwtVerify(token, secret);
Warning: With HS256, anyone who can verify tokens can also create them. Never use HS256 when the verifier should not be able to issue tokens.
| Factor | HS256 | RS256 | ES256 |
|---|---|---|---|
| Key type | Shared secret | RSA key pair | EC key pair |
| Token size | Smallest | Largest | Medium |
| Sign speed | Fast | Slow | Fast |
| Verify speed | Fast | Fast | Medium |
| Key distribution | Secret must be shared | Only public key shared | Only public key shared |
| Best for | Single service | Distributed, legacy | Distributed, modern |
Authorization: Bearer <token>| Token | Lifetime | Storage |
|---|---|---|
| Access token | 5-15 minutes | Memory (SPA), httpOnly cookie (BFF) |
| Refresh token | 7-30 days | httpOnly cookie, secure storage (mobile) |
// Auth server: refresh endpoint
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies;
// 1. Look up the refresh token
const storedToken = await db.refreshTokens.findOne({
token: hash(refreshToken),
});
if (!storedToken) {
// Token not found - might be reuse of revoked token
// Revoke entire token family as precaution
await db.refreshTokens.deleteMany({ family: storedToken?.family });
return res.status(401).json({ error: 'Invalid refresh token' });
}
if (storedToken.revoked) {
// Reuse detected! Revoke entire family
await db.refreshTokens.deleteMany({ family: storedToken.family });
return res.status(401).json({ error: 'Token reuse detected' });
}
if (storedToken.expiresAt < new Date()) {
return res.status(401).json({ error: 'Refresh token expired' });
}
// 2. Revoke the old refresh token
await db.refreshTokens.updateOne(
{ token: hash(refreshToken) },
{ revoked: true }
);
// 3. Issue new tokens
const newAccessToken = await createAccessToken(storedToken.userId);
const newRefreshToken = crypto.randomBytes(32).toString('hex');
// 4. Store new refresh token in same family
await db.refreshTokens.insertOne({
token: hash(newRefreshToken),
userId: storedToken.userId,
family: storedToken.family, // Same family for reuse detection
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
revoked: false,
});
// 5. Return new tokens
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/auth/refresh', // Only sent to refresh endpoint
});
res.json({ accessToken: newAccessToken });
});
Token families track lineage of refresh tokens. If a revoked refresh token is reused (indicating theft), all tokens in the family are invalidated.
Login → RT1 (family: F1)
RT1 → RT2 (family: F1, RT1 revoked)
RT2 → RT3 (family: F1, RT2 revoked)
If attacker uses stolen RT1:
RT1 is revoked → ALERT → revoke all in family F1
User must re-authenticate
// Add to blocklist on logout/revocation
await redis.set(`blocklist:${jti}`, '1', 'EX', tokenRemainingTTL);
// Check on every request
const isRevoked = await redis.get(`blocklist:${jti}`);
if (isRevoked) return res.status(401).json({ error: 'Token revoked' });
// User record has a tokenVersion
// JWT includes tokenVersion claim
// On password change/logout-all: increment tokenVersion
// On verification: compare JWT version with stored version
const user = await db.users.findOne({ id: payload.sub });
if (payload.tokenVersion !== user.tokenVersion) {
return res.status(401).json({ error: 'Token revoked' });
}
// Express + express-session + connect-redis
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
name: '__Host-session', // Cookie name with secure prefix
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // No JS access
sameSite: 'lax', // CSRF protection
maxAge: 24 * 60 * 60 * 1000, // 24 hours
path: '/',
},
rolling: true, // Reset expiry on each request
}));
# FastAPI + Redis sessions
from fastapi import FastAPI, Request, Response
from uuid import uuid4
import redis.asyncio as redis
import json
r = redis.from_url("redis://localhost:6379")
async def create_session(response: Response, user_id: str, data: dict):
session_id = str(uuid4())
session_data = {"user_id": user_id, **data}
await r.setex(f"session:{session_id}", 86400, json.dumps(session_data))
response.set_cookie(
key="__Host-session",
value=session_id,
httponly=True,
secure=True,
samesite="lax",
max_age=86400,
path="/",
)
return session_id
async def get_session(request: Request) -> dict | None:
session_id = request.cookies.get("__Host-session")
if not session_id:
return None
data = await r.get(f"session:{session_id}")
if data:
# Reset TTL (sliding window)
await r.expire(f"session:{session_id}", 86400)
return json.loads(data)
return None
| Backend | Scalability | Persistence | Latency | Use When |
|---|---|---|---|---|
| Memory | Single server | None (lost on restart) | Fastest | Development only |
| Redis | Horizontal | Optional (AOF/RDB) | ~1ms | Production default |
| PostgreSQL | Horizontal | Full | ~5ms | Already using Postgres, need durability |
| MongoDB | Horizontal | Full | ~3ms | Already using MongoDB |
Always regenerate the session ID after authentication state changes:
// Express
app.post('/login', async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
// CRITICAL: Regenerate session ID to prevent fixation
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Session error' });
req.session.userId = user.id;
req.session.role = user.role;
req.session.save((err) => {
if (err) return res.status(500).json({ error: 'Session error' });
res.json({ user: { id: user.id, email: user.email } });
});
});
});
| Value | Behavior | CSRF Protection | Use Case |
|---|---|---|---|
Strict |
Cookie never sent cross-site | Strongest | Internal tools, admin panels |
Lax |
Sent on top-level navigation (GET) | Good (default) | General-purpose sessions |
None |
Sent on all cross-site requests | None (requires Secure) |
Embedded widgets, cross-origin APIs |
Lax vs Strict: Lax allows the session cookie to be sent when a user clicks a link to your site from an external page. Strict does not, so users would appear logged out after clicking a link from an email or social media.
// __Host- prefix (strictest, recommended)
Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
// Requirements for __Host-:
// - Must have Secure flag
// - Must NOT have Domain attribute
// - Must have Path=/
// - Only sent to exact host (no subdomains)
// __Secure- prefix (less strict)
Set-Cookie: __Secure-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
// Requirements for __Secure-:
// - Must have Secure flag
// - Can have Domain attribute
Use __Host- prefix for session cookies. It prevents a subdomain takeover from overwriting your session cookie.
| Aspect | Cookie | Authorization Header |
|---|---|---|
| Automatic sending | Yes (browser sends automatically) | No (must attach manually) |
| CSRF risk | Yes (unless SameSite) | No |
| XSS theft risk | No (if HttpOnly) | Yes (if in accessible storage) |
| Cross-origin | Configurable (SameSite, CORS) | Simple (just add header) |
| Best for | Server-rendered apps, BFF | Pure APIs, mobile apps |
// Generate CSRF token and store in session
import crypto from 'crypto';
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
res.locals.csrfToken = req.session.csrfToken;
next();
});
// Validate on state-changing requests
app.use((req, res, next) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const token = req.headers['x-csrf-token'] || req.body._csrf;
if (!token || token !== req.session.csrfToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
}
next();
});
// Set CSRF token as a separate cookie (NOT httpOnly, so JS can read it)
res.cookie('csrf-token', csrfToken, {
secure: true,
sameSite: 'strict',
// httpOnly: false -- intentionally readable by JS
path: '/',
});
// Client reads cookie value and sends in header
// fetch('/api/data', {
// method: 'POST',
// headers: { 'X-CSRF-Token': getCookie('csrf-token') },
// });
// Server validates: cookie value === header value
SameSite=Lax prevents most CSRF attacks because the cookie is not sent on cross-site POST requests. However, it does not protect against:
Recommendation: Use SameSite=Lax AND a CSRF token for defense-in-depth.
| Aspect | Stateless (JWT) | Stateful (Sessions) |
|---|---|---|
| Server storage | None (token is self-contained) | Session store (Redis, DB) |
| Scalability | Easy (any server can verify) | Requires shared session store |
| Revocation | Hard (need blocklist) | Easy (delete session) |
| Token size | Larger (contains claims) | Smaller (just session ID) |
| Offline verification | Yes (with public key) | No (must query session store) |
| Information leakage | Claims visible (base64) | Server-side only |
| Performance | No DB lookup for verification | DB/cache lookup per request |
| Logout | Complex (blocklist or wait for expiry) | Simple (delete session) |
| Best for | Microservices, APIs, mobile | Monoliths, server-rendered apps |
Many production systems use both:
User login → Session created (server-side)
→ JWT issued for API calls
→ Session manages refresh tokens
→ JWT used for stateless API authorization
Browser ←→ BFF (Backend-for-Frontend) ←→ API
│ │
│ session cookie │ JWT in Authorization header
│ (httpOnly) │ (server-to-server)
The BFF holds tokens server-side and proxies API calls. The browser only has a session cookie.
Access token stored in httpOnly cookie. Requires CSRF protection. Works well for same-origin APIs.
Access token stored in a JavaScript variable. Lost on page refresh (must re-authenticate via refresh token in httpOnly cookie). Safest browser storage for tokens but impacts UX.
| Storage | Problem |
|---|---|
| localStorage | Accessible via XSS, persists across tabs |
| sessionStorage | Accessible via XSS |
| Non-httpOnly cookie | Accessible via XSS |
| URL parameters | Logged by servers, proxies, browser history |
1. Generate new key pair (kid: "key-2025-01")
2. Add new key to JWKS endpoint
3. Start signing new tokens with new key
4. Old tokens still verify (old key still in JWKS)
5. After max token lifetime, remove old key from JWKS
// GET /.well-known/jwks.json
{
"keys": [
{
"kty": "RSA",
"kid": "key-2025-01",
"use": "sig",
"alg": "RS256",
"n": "...",
"e": "AQAB"
},
{
"kty": "RSA",
"kid": "key-2024-01",
"use": "sig",
"alg": "RS256",
"n": "...",
"e": "AQAB"
}
]
}
// Verify JWT with JWKS (jose library)
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json')
);
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
Every JWT verification should check:
alg: none attack)exp) has not passednbf) has passed (if present)iss) matches expected valueaud) matches your servicekid) maps to a known key| Attack | Description | Prevention |
|---|---|---|
alg: none |
Attacker removes signature | Always validate alg against allowlist |
| Key confusion (RS256→HS256) | Attacker signs with public key as HMAC secret | Explicitly specify expected algorithm |
| Token substitution | Access token used as refresh (or vice versa) | Include token type in claims |
| JWK injection | Attacker includes key in JWT header | Only trust keys from your JWKS endpoint |
| Expired token replay | Attacker replays old token | Always validate exp claim |