jwt-sessions.md 20 KB

JWT and Session Management

Deep-dive reference for JSON Web Tokens, session-based authentication, cookie security, and CSRF protection.

JWT Structure

A JWT consists of three Base64URL-encoded parts separated by dots.

Header

{
  "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)

Payload (Claims)

Registered Claims (RFC 7519)

{
  "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)

Custom Claims

{
  "role": "admin",
  "permissions": ["read", "write", "delete"],
  "org_id": "org_456",
  "tenant": "acme-corp"
}

Guidelines for custom claims:

  • Namespace custom claims to avoid collisions: https://example.com/role
  • Keep payload small (< 1KB) -- JWTs are sent with every request
  • Never put sensitive data in claims (tokens are encoded, not encrypted)
  • Include only what the resource server needs for authorization decisions

Signature

RSASHA256(
  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).

Signing Algorithms

RS256 (RSA + SHA-256)

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"))

ES256 (ECDSA + SHA-256)

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:

  • ES256 signatures: 64 bytes vs RS256: 256 bytes
  • ES256 key generation: faster
  • ES256 signing: faster
  • ES256 verification: slightly slower
  • Both are equally secure for JWT purposes

HS256 (HMAC + SHA-256)

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.

Algorithm Selection Matrix

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

Access + Refresh Token Pattern

Flow

  1. User authenticates (login with credentials, OAuth2, etc.)
  2. Auth server issues access token (short-lived) and refresh token (long-lived)
  3. Client uses access token for API requests via Authorization: Bearer <token>
  4. When access token expires, client sends refresh token to get new tokens
  5. Auth server validates refresh token, issues new access + refresh tokens
  6. Old refresh token is invalidated (rotation)

Token Lifetimes

Token Lifetime Storage
Access token 5-15 minutes Memory (SPA), httpOnly cookie (BFF)
Refresh token 7-30 days httpOnly cookie, secure storage (mobile)

Refresh Token Rotation

// 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 Family Detection

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

Token Revocation Strategies

Strategy 1: Short Expiry + No Revocation

  • Access tokens expire in 5-15 minutes
  • No revocation mechanism needed
  • Revoke refresh token to prevent renewal
  • Trade-off: Cannot immediately invalidate access tokens

Strategy 2: Blocklist (Redis)

// 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' });
  • Entries auto-expire when the token would have expired
  • Trade-off: Requires Redis, adds latency to every request

Strategy 3: Version-Based Revocation

// 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' });
}
  • Revokes all tokens for a user at once
  • Trade-off: Requires DB lookup per request (but can cache)

Session-Based Authentication

Server-Side Sessions

// 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

Session Storage Backends

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

Session Fixation Prevention

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 } });
    });
  });
});

Cookie Security

Cookie Attributes Deep Dive

SameSite

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.

Secure Prefix Cookies

// __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.

Cookie vs Authorization Header

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

CSRF Protection

Synchronizer Token Pattern

// 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();
});

Double-Submit Cookie Pattern

// 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 as Defense-in-Depth

SameSite=Lax prevents most CSRF attacks because the cookie is not sent on cross-site POST requests. However, it does not protect against:

  • Subdomain attacks
  • GET-based state changes (which you should not have)
  • Browser bugs

Recommendation: Use SameSite=Lax AND a CSRF token for defense-in-depth.

Stateless vs Stateful Authentication

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

Hybrid Approach

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

Token Storage for SPAs

Option 1: BFF Pattern (Recommended)

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.

Option 2: HttpOnly Cookie

Access token stored in httpOnly cookie. Requires CSRF protection. Works well for same-origin APIs.

Option 3: In-Memory (JavaScript Variable)

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.

What NOT to Do

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

Key Rotation

Why Rotate Keys

  • Limit exposure if a key is compromised
  • Compliance requirements
  • Cryptographic best practice

Rotation Process

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

JWKS (JSON Web Key Set) Endpoint

// 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',
});

JWT Validation Checklist

Every JWT verification should check:

  • Signature is valid
  • Algorithm matches expected (prevent alg: none attack)
  • Expiration (exp) has not passed
  • Not Before (nbf) has passed (if present)
  • Issuer (iss) matches expected value
  • Audience (aud) matches your service
  • Token type is correct (access vs refresh)
  • Key ID (kid) maps to a known key

Common JWT Attacks

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