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.
- 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.
- 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.
- 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:
- Access Tokens are short-lived (15m) and sent to the client in the JSON body (held in memory, never persisted).
- Refresh Tokens are long-lived (7d), sent via strictly configured
HttpOnlycookies, and rotated on every use. - 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
- XSS mitigation: The Refresh Token is in an
HttpOnlycookie. Even if an attacker injects a script, they cannot readdocument.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. - CSRF mitigation: We use
SameSite: Strict. Additionally, the Refresh Token is only sent to the/refreshendpoint. 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. - 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
familyIdand 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.