Skip to main content

Secure Authentication 2025: Implementing HttpOnly Cookie Sessions vs. JWT Rotation

 

The Reality of Client-Side Storage

It is 2025, and we still see Senior Developers storing Access Tokens in localStorage.

Let’s be unequivocal: localStorage is not a secure vault. It is a global key-value store accessible by any JavaScript executing on your origin. If your application has a single XSS vulnerability—whether through a compromised npm package, a rogue third-party analytics script, or improper input sanitization—your user's entire identity is compromised. The attacker simply reads localStorage.getItem('accessToken') and sends it to their server.

However, the alternative—standard HttpOnly cookies—introduces friction for mobile applications (which prefer Authorization headers) and requires strict CSRF mitigation strategies.

The architectural compromise that satisfies strict security requirements while remaining platform-agnostic is Refresh Token Rotation (RTR) with Reuse Detection, backed by Redis.

The Root Cause: Why Stateless JWTs Fail

The allure of JWTs was "statelessness"—the server doesn't need to look up a database to validate a request. This works for microservices but fails for session management.

  1. Immutability: You cannot invalidate a stateless JWT before it expires. If you issue a token valid for 1 hour and it gets stolen, the attacker has 1 hour of access.
  2. The Revocation Gap: To mitigate theft, we shorten Access Token headers (e.g., 5 mins) and use long-lived Refresh Tokens. But if the Refresh Token is stolen, the attacker can generate new Access Tokens indefinitely.
  3. Lack of Observability: Stateless authentication provides no insight into active sessions or concurrent usage.

To fix this, we must introduce state (Redis) to the "stateless" standard (JWT).

The Fix: Refresh Token Rotation with Redis-Backed Reuse Detection

We will implement a system where:

  1. Access Tokens are short-lived (15m) and sent to the client in the JSON body (held in memory, never persisted).
  2. Refresh Tokens are long-lived (7d), sent via strictly configured HttpOnly cookies, and rotated on every use.
  3. Redis tracks token families. If a rotated refresh token is used again (indicating theft), the entire family is blocked.

Prerequisites

npm install express ioredis jsonwebtoken cookie-parser helmet

1. The Redis Layer (State Management)

We need an abstraction to manage token families. We aren't storing the JWTs themselves, but rather the cryptographic relationship between a user and their current valid Refresh Token.

// src/services/tokenService.js
import Redis from 'ioredis';
import crypto from 'node:crypto';

// Initialize Redis (Ensure you handle connection errors in production)
const redis = new Redis(process.env.REDIS_URL);

const REFRESH_EXPIRY = 60 * 60 * 24 * 7; // 7 Days

/**
 * Key Schema:
 * refresh_token:{userId}:{familyId} -> "valid_token_hash"
 * blocklist:{userId}:{familyId} -> "blocked"
 */

export const tokenService = {
  /**
   * Generates a handle for a new login session.
   * A 'familyId' links a chain of rotated tokens.
   */
  async createSession(userId) {
    const familyId = crypto.randomUUID();
    return familyId;
  },

  /**
   * Rotates the token.
   * If the provided token hash doesn't match what's in Redis,
   * it means a token is being reused (potential theft).
   */
  async rotateToken(userId, familyId, oldTokenHash, newTokenHash) {
    const key = `refresh_token:${userId}:${familyId}`;
    const blockKey = `blocklist:${userId}:${familyId}`;

    // 1. Check if family is blocked
    const isBlocked = await redis.get(blockKey);
    if (isBlocked) {
      throw new Error('SecurityException: Token family blocked due to reuse detection.');
    }

    // 2. Atomic check and set
    // If oldTokenHash is null, it's a new login.
    // If oldTokenHash is provided, it must match the value in Redis.
    
    if (!oldTokenHash) {
      // New Login
      await redis.setex(key, REFRESH_EXPIRY, newTokenHash);
      return;
    }

    // Lua script to ensure atomicity during rotation
    // Checks if current value matches oldTokenHash. 
    // If yes, sets new value. If no, returns failure (0).
    const luaScript = `
      if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("setex", KEYS[1], ARGV[2], ARGV[3])
      else
        return 0
      end
    `;

    const result = await redis.eval(luaScript, 1, key, oldTokenHash, REFRESH_EXPIRY, newTokenHash);

    if (result === 0) {
      // REUSE DETECTED!
      // The token submitted is not the one we expected.
      // This means the user (or attacker) is using an old token.
      // Nuke the entire family.
      await redis.setex(blockKey, REFRESH_EXPIRY, 'blocked');
      await redis.del(key);
      throw new Error('SecurityException: Refresh Token Reuse Detected. Session revoked.');
    }
  },
  
  // Utility to hash tokens so we don't store raw JWTs in Redis
  hashToken(token) {
    return crypto.createHash('sha256').update(token).digest('hex');
  }
};

2. The JWT Utilities

Standard signing and verification. Note that the Refresh Token contains the familyId.

// src/utils/jwt.js
import jwt from 'jsonwebtoken';

const ACCESS_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET;

export const signAccessToken = (user) => {
  return jwt.sign(
    { sub: user.id, role: user.role }, 
    ACCESS_SECRET, 
    { expiresIn: '15m' }
  );
};

export const signRefreshToken = (user, familyId) => {
  return jwt.sign(
    { sub: user.id, familyId }, // Embed familyId in the token
    REFRESH_SECRET, 
    { expiresIn: '7d' }
  );
};

export const verifyRefreshToken = (token) => {
  try {
    return jwt.verify(token, REFRESH_SECRET);
  } catch (error) {
    return null;
  }
};

3. The Authentication Controller

Here is the core logic handling the rotation and cookie setting.

// src/controllers/authController.js
import { tokenService } from '../services/tokenService.js';
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../utils/jwt.js';

// Cookie Options for Production
const COOKIE_OPTIONS = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production', // HTTPS only
  sameSite: 'strict', // Protects against CSRF
  path: '/api/auth/refresh', // Limits cookie transmission to specific endpoint
  maxAge: 7 * 24 * 60 * 60 * 1000 // 7 Days
};

export const login = async (req, res) => {
  // ... validate user credentials (omitted for brevity) ...
  const user = req.user; 

  const familyId = await tokenService.createSession(user.id);
  const refreshToken = signRefreshToken(user, familyId);
  const accessToken = signAccessToken(user);

  // Hash and store initial state in Redis
  const tokenHash = tokenService.hashToken(refreshToken);
  await tokenService.rotateToken(user.id, familyId, null, tokenHash);

  // Send Refresh Token in Cookie
  res.cookie('__Host-refresh_token', refreshToken, COOKIE_OPTIONS);

  // Send Access Token in Body
  res.json({ accessToken });
};

export const refresh = async (req, res) => {
  const oldRefreshToken = req.cookies['__Host-refresh_token'];
  
  if (!oldRefreshToken) {
    return res.status(401).json({ message: 'No refresh token provided' });
  }

  // 1. Verify Signature
  const decoded = verifyRefreshToken(oldRefreshToken);
  
  if (!decoded) {
    // Token is tampered or expired
    res.clearCookie('__Host-refresh_token', COOKIE_OPTIONS);
    return res.status(403).json({ message: 'Invalid token' });
  }

  const { sub: userId, familyId } = decoded;

  try {
    // 2. Generate New Tokens
    const newRefreshToken = signRefreshToken({ id: userId }, familyId);
    const newAccessToken = signAccessToken({ id: userId });

    // 3. Attempt Rotation in Redis (Checks for reuse)
    const oldHash = tokenService.hashToken(oldRefreshToken);
    const newHash = tokenService.hashToken(newRefreshToken);
    
    await tokenService.rotateToken(userId, familyId, oldHash, newHash);

    // 4. Send new tokens
    res.cookie('__Host-refresh_token', newRefreshToken, COOKIE_OPTIONS);
    res.json({ accessToken: newAccessToken });

  } catch (error) {
    // Capture the SecurityException from Redis service
    console.error(`[Security Alert] ${error.message}`);
    
    // Clear cookies immediately
    res.clearCookie('__Host-refresh_token', COOKIE_OPTIONS);
    
    return res.status(403).json({ 
      error: 'Session invalid', 
      code: 'TOKEN_REUSE_DETECTED' 
    });
  }
};

The Explanation

Why this architecture is superior

  1. XSS mitigation: The Refresh Token is in an HttpOnly cookie. Even if an attacker injects a script, they cannot read document.cookie. They might be able to read the Access Token from memory if they hook into the application state, but that token dies in 15 minutes.
  2. CSRF mitigation: We use SameSite: Strict. Additionally, the Refresh Token is only sent to the /refresh endpoint. The actual API calls use the Access Token in the Authorization header, which is immune to CSRF because headers aren't sent automatically by browsers.
  3. The "Honeypot" (Reuse Detection):
    • Scenario: An attacker manages to steal a Refresh Token (perhaps via a Man-in-the-Middle attack on a non-secure network or a browser bug).
    • The legitimate user eventually triggers a refresh (auto-refresh in the background).
    • The legitimate user's token rotates the key in Redis.
    • The attacker attempts to use the stolen (now old) token.
    • Redis sees the mismatch. It assumes a breach has occurred.
    • Action: It deletes the Redis key for that familyId and adds it to a blocklist.
    • Result: Both the legitimate user and the attacker are logged out. The legitimate user is forced to re-authenticate, ensuring the account is secured.

Conclusion

Security is a moving target. Storing tokens in localStorage was acceptable in 2015; in 2025, it is negligence.

By combining the stateless scalability of JWTs with the stateful control of Redis, we achieve a robust authentication system. We gain the ability to revoke sessions instantly, detect token theft automatically, and keep sensitive credentials out of the reach of client-side scripts.

Implement this pattern today. Your users' data depends on it.