and validated by backend middleware. This approach decouples identity verification from business logic while maintaining strict control over session lifecycle.
Step 1: Identity Provider Configuration
Configure an OIDC-compliant IdP (e.g., Keycloak, Auth0, or a custom implementation using openid-client). The IdP must issue short-lived Access Tokens (AT) and long-lived Refresh Tokens (RT).
- Access Token:
alg: RS256, exp: 15m, contains sub, aud, scope.
- Refresh Token: Opaque string,
exp: 30d, stored server-side with metadata.
- PKCE: Enforce PKCE for all public clients to prevent authorization code interception.
Step 2: Token Issuance Flow
Implement the Authorization Code Flow with PKCE. The backend issues tokens only after verifying the authorization code and PKCE verifier.
// src/auth/issuer.ts
import { Issuer, generators } from 'openid-client';
export async function issueTokens(clientId: string, code: string, codeVerifier: string) {
const issuer = await Issuer.discover(process.env.ISSUER_URL);
const client = new issuer.Client({
client_id: clientId,
token_endpoint_auth_method: 'none', // Public client
});
const tokenSet = await client.callback(
process.env.REDIRECT_URI,
{ code },
{ code_verifier: codeVerifier, state: generators.state() }
);
// Store session metadata in Redis for revocation
await sessionStore.create({
sessionId: tokenSet.claims().sid,
sub: tokenSet.claims().sub,
issuedAt: Date.now(),
refreshTokenHash: hash(tokenSet.refresh_token!),
});
return {
accessToken: tokenSet.access_token,
refreshToken: tokenSet.refresh_token,
expiresIn: tokenSet.expires_in,
};
}
Step 3: Validation Middleware with Session Check
Backend middleware must validate the JWT signature and verify the session exists in the store. This prevents replay attacks with revoked tokens.
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URI!));
export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.split(' ')[1];
try {
// 1. Cryptographic validation
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.ISSUER_URL,
audience: process.env.API_AUDIENCE,
});
// 2. Session existence check (Revocation)
const session = await sessionStore.get(payload.sid as string);
if (!session) {
return res.status(401).json({ error: 'Session revoked' });
}
// 3. Attach user context
req.user = {
sub: payload.sub,
roles: payload.scope?.split(' ') || [],
sessionId: payload.sid,
};
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}
Step 4: Refresh Token Rotation
Refresh tokens must be rotated on every use. If a refresh token is reused, it indicates theft; the system must revoke the entire family of tokens.
// src/auth/rotation.ts
export async function rotateRefreshToken(oldToken: string) {
const session = await sessionStore.findByRefreshTokenHash(hash(oldToken));
if (!session) {
throw new Error('Invalid refresh token');
}
// Check for token reuse (theft detection)
if (session.currentRefreshTokenHash !== hash(oldToken)) {
// Revocation cascade
await sessionStore.revokeFamily(session.sub);
throw new Error('Refresh token reuse detected. Session terminated.');
}
// Issue new tokens
const newRefreshToken = generateSecureRandom();
await sessionStore.update(session.sessionId, {
currentRefreshTokenHash: hash(newRefreshToken),
lastUsed: Date.now(),
});
return {
accessToken: issueAccessToken(session),
refreshToken: newRefreshToken,
};
}
Architecture Decisions
- RS256 over HS256: Asymmetric signing allows resource servers to validate tokens without sharing secrets, reducing the blast radius of key compromise.
- Opaque Refresh Tokens: Refresh tokens are never parsed by resource servers. They are opaque handles to the session store, preventing data leakage and enabling immediate revocation.
- Redis Session Store: Provides sub-millisecond lookups for validation and atomic operations for rotation logic. TTLs ensure automatic cleanup of expired sessions.
- Short-Lived Access Tokens: 15-minute expiry limits the window of exploitation for stolen tokens, while refresh rotation maintains user experience.
Pitfall Guide
-
Storing Tokens in localStorage:
- Mistake: Frontend stores JWTs in
localStorage.
- Impact: Vulnerable to XSS. Any script running on the page can exfiltrate tokens.
- Fix: Use
HttpOnly, Secure, SameSite=Strict cookies for token storage, or implement a token exchange flow where the frontend never touches the raw token.
-
Ignoring aud and iss Validation:
- Mistake: Validating signature but not claims.
- Impact: Token confusion attacks. A token issued for Service A can be used on Service B.
- Fix: Strictly validate
iss (issuer) and aud (audience) in middleware. Ensure audience values are unique per service.
-
Race Conditions in Refresh Rotation:
- Mistake: Concurrent refresh requests overwrite each other, causing token desynchronization.
- Impact: Legitimate users are logged out unexpectedly.
- Fix: Use distributed locks (e.g., Redis
SET NX) around the rotation logic or implement a grace period for recently used tokens.
-
Excessive JWT Payload Size:
- Mistake: Embedding user profiles or large role lists in the JWT.
- Impact: Increased latency, header size limits exceeded, and stale data.
- Fix: Keep JWTs minimal. Use
sub and scope. Fetch user details from the user service via sub when needed.
-
Lack of Rate Limiting on Auth Endpoints:
- Mistake: Allowing unlimited login or token refresh attempts.
- Impact: Credential stuffing and brute-force attacks.
- Fix: Implement strict rate limiting on
/login, /token, and /refresh endpoints based on IP and user identifier.
-
CSRF on Stateless APIs:
- Mistake: Assuming JWTs prevent CSRF.
- Impact: If tokens are stored in cookies, cross-site request forgery is possible.
- Fix: Use
SameSite=Strict cookies, implement CSRF tokens for state-changing requests, or use the Double Submit Cookie pattern.
-
Static JWKS Rotation:
- Mistake: Hardcoding public keys or failing to rotate keys.
- Impact: If a private key is compromised, all tokens are forgeable.
- Fix: Use dynamic JWKS fetching with caching. Rotate signing keys periodically and ensure the IdP publishes new keys before old ones expire.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal Microservices | mTLS + Short-lived JWT | Service-to-service trust; low latency; no user interaction. | Low (Infrastructure overhead) |
| Public Web App (SPA) | Session-Backed JWT + HttpOnly Cookies | XSS mitigation; instant revocation; standard UX. | Medium (Redis session store) |
| Mobile App | OIDC + Refresh Token Rotation | Native storage security; background refresh; offline capability. | Low (Client-side storage) |
| High-Security Finance | OIDC + Step-up Auth + MFA | Strict identity assurance; regulatory compliance; risk-based auth. | High (MFA providers, complexity) |
| Legacy Migration | Adapter Pattern + JWT Wrapper | Bridge legacy session stores to modern JWT flows without rewrite. | Medium (Dev effort) |
Configuration Template
Docker Compose for Local Auth Stack:
version: '3.8'
services:
keycloak:
image: quay.io/keycloak/keycloak:23.0
command: start-dev
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
volumes:
- ./realm-export.json:/opt/keycloak/data/import/realm.json
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --requirepass ${REDIS_PASSWORD}
api:
build: .
environment:
ISSUER_URL: http://localhost:8080/realms/codcompass
JWKS_URI: http://keycloak:8080/realms/codcompass/protocol/openid-connect/certs
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
API_AUDIENCE: backend-api
ports:
- "3000:3000"
depends_on:
- keycloak
- redis
TypeScript Auth Config Interface:
// src/config/auth.config.ts
export interface AuthConfig {
issuer: string;
jwksUri: string;
audience: string;
accessTokenTTL: number; // seconds
refreshTokenTTL: number; // seconds
sessionStore: {
type: 'redis' | 'memory';
url: string;
ttl: number;
};
cookie: {
httpOnly: boolean;
secure: boolean;
sameSite: 'strict' | 'lax' | 'none';
maxAge: number;
};
}
export const authConfig: AuthConfig = {
issuer: process.env.ISSUER_URL!,
jwksUri: process.env.JWKS_URI!,
audience: process.env.API_AUDIENCE!,
accessTokenTTL: 15 * 60,
refreshTokenTTL: 30 * 24 * 60 * 60,
sessionStore: {
type: 'redis',
url: process.env.REDIS_URL!,
ttl: 30 * 24 * 60 * 60,
},
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60 * 1000,
},
};
Quick Start Guide
-
Initialize Environment:
Run docker compose up -d to start Keycloak and Redis. Import the realm configuration to set up clients and scopes.
-
Install Dependencies:
Execute npm install jose openid-client redis express. Ensure TypeScript types are installed for strict typing.
-
Configure Auth Middleware:
Copy the authMiddleware and rotation logic into your project. Update auth.config.ts with your environment variables pointing to the local Docker services.
-
Test Token Flow:
Use a tool like Postman to request a token via the Authorization Code flow with PKCE. Verify the JWT structure, validate the signature against the JWKS, and confirm the session is created in Redis.
-
Validate Protection:
Attempt to access a protected endpoint with an expired token and a revoked session ID. Ensure the middleware returns 401 Unauthorized with appropriate error messages.