Skip to content
Scaling Authentication: Robust JWT & Refresh Token Management in Distributed Systems

Scaling Authentication: Robust JWT & Refresh Token Management in Distributed Systems

10 min read
JWTAuthenticationRedisNode.jsMicroservices

Implementing secure and scalable authentication in distributed systems often falls short with basic JWTs. Learn how to architect a robust system combining short-lived access tokens with revocable refresh tokens to solve common security and session management challenges.

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:

  1. Login: User authenticates. The authentication service issues both a short-lived access token and a long-lived refresh token.
  2. 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.
  3. Resource Access: The client sends the access token with each request to protected API endpoints. Services validate the access token's signature and expiry.
  4. Access Token Expiry & Refresh: When an access token expires, the client sends the refresh token to a dedicated /refresh endpoint. 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.
  5. 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:

BASH
├── 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:

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

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

TYPESCRIPT
// 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.json endpoint. They verify token authenticity locally with zero database or Redis calls.
VBNET
┌────────────────────────────────────────────────────────────────────────┐
│               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.

Muhammad Tahir logo

Muhammad Tahir

Building web & mobile apps since 2021. Passionate about clean code and real-world impact.