1. Introduction & The Problem
In the world of modern web applications, especially those built as microservices or distributed systems, authentication is a critical, yet often underestimated, component. JSON Web Tokens (JWTs) have emerged as a popular choice due to their stateless nature, allowing services to verify user identity without a centralized session store. However, this very statelessness, while offering scalability benefits, introduces significant challenges for real-world applications:
- Instant Revocation: How do you immediately log out a compromised user or revoke access when an employee leaves the company? Stateless JWTs, by design, cannot be revoked until they expire. This creates a security window where an attacker with a stolen token can maintain access.
- Long-Lived Sessions: For a seamless user experience, users expect to remain logged in for extended periods. Issuing long-lived JWTs is a major security risk, as a compromised token grants prolonged unauthorized access.
- Session Management: How do you implement features like 'log out from all devices' or view active sessions? Without state, tracking individual sessions becomes impossible.
- Scalability of State: If you decide to add state (e.g., a blacklist) for revocation, how do you ensure this scales efficiently across numerous microservices without becoming a performance bottleneck or single point of failure?
Leaving these problems unaddressed can lead to severe security vulnerabilities, compliance issues, and a poor user experience. A data breach due to an unrevoked token can cost millions in damages, reputational loss, and customer trust. Enterprises need a solution that combines the scalability of JWTs with the security and control of traditional session management.
2. The Solution Concept & Architecture
The industry-standard solution for robust, scalable authentication in distributed systems combines short-lived JWTs (access tokens) with long-lived, revocable refresh tokens. This hybrid approach leverages the best of both worlds:
- Access Tokens (JWTs): These are short-lived (e.g., 5-15 minutes) and are sent with every API request to access protected resources. Their short lifespan minimizes the impact of a compromised token. Being stateless, they allow services to quickly verify requests without database lookups.
- Refresh Tokens: These are long-lived (e.g., 7 days to 30 days) and are used *only* to obtain new access tokens once the current one expires. Unlike access tokens, refresh tokens *are* stateful and are stored securely in a database (like Redis or PostgreSQL). This state allows for instant revocation.
Architecture Flow:
- Login: User authenticates. The authentication service issues both a short-lived access token and a long-lived refresh token.
- Token Storage: The access token is stored in memory or a secure client-side mechanism (e.g., HTTP-only cookie). The refresh token is stored in an HTTP-only cookie to prevent XSS attacks and saved in a secure server-side store (e.g., Redis or a database) associated with the user.
- Resource Access: The client sends the access token with each request to protected API endpoints. Services validate the access token's signature and expiry.
- Access Token Expiry & Refresh: When an access token expires, the client sends the refresh token to a dedicated
/refreshendpoint. The server validates the refresh token against its stored copy, issues a new access token (and optionally a new refresh token for rotation), and sends them back to the client. - Logout/Revocation: Upon logout, the server deletes the refresh token from its store, immediately invalidating the session. Similarly, an administrator can revoke any specific refresh token or all refresh tokens for a user.
This architecture provides the performance benefits of stateless JWTs for general API access while centralizing control and security through stateful refresh token management.
3. Step-by-Step Implementation
Let's implement this using Node.js, Express, `jsonwebtoken` for JWTs, and Redis for refresh token storage.
Prerequisites:
- Node.js installed
- Redis server running
- `npm install express jsonwebtoken redis`
Project Structure:
├── server.js
├── authService.js
├── redisClient.js
└── .env
1. `redisClient.js` - Redis Configuration
Establish a connection to Redis. This client will be used to store and retrieve refresh tokens### 1. src/redisClient.ts - Distributed Token Store
We use ioredis to maintain active session metadata, refresh token families, and instant revocation lists with automatic Redis TTL expiration:
// src/redisClient.ts
import Redis from "ioredis";
const REDIS_URI = process.env.REDIS_URL || "redis://localhost:6379";
export const redis = new Redis(REDIS_URI, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
});
redis.on("connect", () => console.log("Connected to Redis token session store"));
redis.on("error", (err) => console.error("Redis connection error:", err));
2. src/authService.ts - Token Generation, Rotation, and Theft Detection
This service implements Refresh Token Rotation (RTR) with Automatic Reuse Detection. If an attacker intercepts a refresh token and attempts to use it after the legitimate user has already refreshed, the system detects token theft and immediately purges all active tokens for that user session family:
// src/authService.ts
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { redis } from "./redisClient";
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || "super-secret-access-key-minimum-32-chars";
const ACCESS_TOKEN_TTL_SECONDS = 15 * 60; // 15 minutes
const REFRESH_TOKEN_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
export interface UserSessionPayload {
userId: string;
email: string;
role: string;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export class AuthService {
// Generate a short-lived stateless JWT access token
public static generateAccessToken(payload: UserSessionPayload): string {
return jwt.sign(payload, ACCESS_TOKEN_SECRET, {
expiresIn: ACCESS_TOKEN_TTL_SECONDS,
algorithm: "HS256",
});
}
// Generate an opaque cryptographically secure 64-byte refresh token
public static generateOpaqueToken(): string {
return crypto.randomBytes(64).toString("hex");
}
// Issue a new token pair and register the family in Redis
public static async createSession(user: UserSessionPayload): Promise<TokenPair> {
const familyId = crypto.randomUUID();
const refreshToken = this.generateOpaqueToken();
const accessToken = this.generateAccessToken(user);
// Store active refresh token in Redis with user context and family ID
const sessionData = {
userId: user.userId,
email: user.email,
role: user.role,
familyId,
};
const redisKey = `refresh_token:${refreshToken}`;
await redis.set(redisKey, JSON.stringify(sessionData), "EX", REFRESH_TOKEN_TTL_SECONDS);
// Track active token within family for reuse detection
await redis.sadd(`family:${familyId}`, refreshToken);
await redis.expire(`family:${familyId}`, REFRESH_TOKEN_TTL_SECONDS);
return { accessToken, refreshToken, expiresIn: ACCESS_TOKEN_TTL_SECONDS };
}
// Refresh Token Rotation (RTR) with Token Theft Detection
public static async rotateTokens(oldRefreshToken: string): Promise<TokenPair> {
const redisKey = `refresh_token:${oldRefreshToken}`;
const cachedData = await redis.get(redisKey);
// --- REUSE DETECTION (THEFT DETECTED) ---
if (!cachedData) {
// Check if this token was a previously consumed token from a known family
const usedFamily = await redis.get(`used_token:${oldRefreshToken}`);
if (usedFamily) {
console.error(`🚨 SECURITY ALERT: Refresh token reuse detected for family ${usedFamily}! Revoking all sessions.`);
// Token was stolen and reused! Invalidate entire token family immediately
const familyTokens = await redis.smembers(`family:${usedFamily}`);
for (const token of familyTokens) {
await redis.del(`refresh_token:${token}`);
}
await redis.del(`family:${usedFamily}`);
}
throw new Error("Invalid or revoked refresh token.");
}
const session: UserSessionPayload & { familyId: string } = JSON.parse(cachedData);
// 1. Invalidate old refresh token
await redis.del(redisKey);
// 2. Mark old token as "consumed" with a 24-hour TTL for theft detection
await redis.set(`used_token:${oldRefreshToken}`, session.familyId, "EX", 86400);
// 3. Issue fresh tokens
const newRefreshToken = this.generateOpaqueToken();
const newAccessToken = this.generateAccessToken({
userId: session.userId,
email: session.email,
role: session.role,
});
// 4. Save new refresh token in Redis
const newSessionData = {
userId: session.userId,
email: session.email,
role: session.role,
familyId: session.familyId,
};
await redis.set(`refresh_token:${newRefreshToken}`, JSON.stringify(newSessionData), "EX", REFRESH_TOKEN_TTL_SECONDS);
await redis.sadd(`family:${session.familyId}`, newRefreshToken);
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiresIn: ACCESS_TOKEN_TTL_SECONDS,
};
}
// Instant Revocation / Logout
public static async revokeSession(refreshToken: string): Promise<void> {
const redisKey = `refresh_token:${refreshToken}`;
const cached = await redis.get(redisKey);
if (cached) {
const session = JSON.parse(cached);
await redis.del(redisKey);
await redis.del(`family:${session.familyId}`);
}
}
}
3. src/server.ts - Express Authentication Server & Protected Routes
// src/server.ts
import express, { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { AuthService } from "./authService";
const app = express();
app.use(express.json());
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || "super-secret-access-key-minimum-32-chars";
// JWT Authentication Middleware for Protected Microservices
function authenticateJwt(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return res.status(401).json({ error: "Missing authorization bearer token." });
}
jwt.verify(token, ACCESS_TOKEN_SECRET, (err, decodedUser) => {
if (err) {
return res.status(403).json({ error: "Invalid or expired access token." });
}
(req as any).user = decodedUser;
next();
});
}
// 1. User Login Route
app.post("/api/auth/login", async (req: Request, res: Response) => {
const { email, password } = req.body;
// Verify credentials against DB (simulated)
if (email === "dev@example.com" && password === "StrongPassword123!") {
const userPayload = { userId: "usr_9918", email, role: "ADMIN" };
const tokens = await AuthService.createSession(userPayload);
// Set Refresh Token inside secure, httpOnly cookie
res.cookie("refreshToken", tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return res.json({
accessToken: tokens.accessToken,
expiresIn: tokens.expiresIn,
});
}
return res.status(401).json({ error: "Invalid credentials." });
});
// 2. Token Refresh Route with Rotation
app.post("/api/auth/refresh", async (req: Request, res: Response) => {
const refreshToken = req.body.refreshToken || req.headers["x-refresh-token"];
if (!refreshToken) {
return res.status(400).json({ error: "Refresh token is required." });
}
try {
const newTokens = await AuthService.rotateTokens(refreshToken);
return res.json(newTokens);
} catch (err: any) {
return res.status(403).json({ error: err.message });
}
});
// 3. Explicit Logout & Immediate Revocation
app.post("/api/auth/logout", async (req: Request, res: Response) => {
const { refreshToken } = req.body;
if (refreshToken) {
await AuthService.revokeSession(refreshToken);
}
return res.json({ message: "Successfully logged out. Session revoked." });
});
// 4. Protected Enterprise Endpoint
app.get("/api/dashboard/stats", authenticateJwt, (req: Request, res: Response) => {
return res.json({
message: "Authorized access granted.",
user: (req as any).user,
data: { activeNodes: 42, clusterHealth: "GREEN" },
});
});
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => console.log(`Auth service running on http://localhost:${PORT}`));
4. Asymmetric Key Architecture in Microservices (RS256 vs HS256)
In large distributed microservices, sharing a symmetric secret (HS256) with every service is a major security vulnerability. If one microservice is compromised, an attacker can forge access tokens for the entire ecosystem.
The Solution: RS256 Asymmetric Cryptography
- Auth Service: Holds the Private Key (
private.pem) to sign JWT access tokens. - Downstream Microservices (Billing, Orders, Analytics): Only hold the Public Key (
public.pem) or query the Auth Service's/.well-known/jwks.jsonendpoint. They verify token authenticity locally with zero database or Redis calls.
┌────────────────────────────────────────────────────────────────────────┐
│ Central Auth Service (Holds Private Key) │
│ Signs JWT Access Token with RS256 │
└───────────────────────────────────┬────────────────────────────────────┘
│ User requests with JWT Bearer
┌────────────────┴────────────────┐
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ Billing Microservice │ │ Order Microservice │
│ Verifies with Public Key │ │ Verifies with Public Key │
│ (Zero Network Calls to DB!) │ │ (Zero Network Calls to DB!) │
└──────────────────────────────┘ └──────────────────────────────┘
Distributed Authentication Production Checklist
- Short Access Token Expiry: Access tokens expire in 10 to 15 minutes; never issue multi-day access tokens.
- Refresh Token Rotation (RTR): Every token refresh invalidates the previous refresh token and issues a new one.
- Token Reuse Detection: If an invalidated refresh token is reused, all tokens in that family are immediately purged.
- Asymmetric Verification (RS256): Downstream services verify tokens using public keys without sharing private signing keys.
- Secure Storage: Refresh tokens are stored in
httpOnly,secure,sameSite: 'strict'cookies on browsers to prevent XSS exfiltration. - Redis Cluster High-Availability: Redis session store runs in Sentinel or Cluster mode with persistence (
appendonly yes).
Conclusion
Stateless JWTs provide unparalleled performance for microservices, but without stateful session management, they introduce dangerous security blind spots. By pairing short-lived RS256 access tokens with stateful, rotated refresh tokens in Redis, engineering teams achieve the best of both worlds: blazing-fast stateless API verification across hundreds of microservices alongside instant session revocation, device management, and enterprise-grade breach detection.


