Secure JWT Authentication in Node.js: Implementing Refresh Token Rotation and Revocation
A practical, code-heavy guide to building bulletproof JWT authentication in Node.js using stateless access tokens, secure stateful refresh token rotation, sliding windows, and immediate revocation.
Secure JWT Authentication in Node.js: Implementing Refresh Token Rotation and Revocation
JSON Web Tokens (JWTs) have become the industry standard for modern API authentication. However, implementing them incorrectly is surprisingly easy. A naive implementation—storing long-lived JWTs in localStorage without a revocation mechanism—leaves your application wide open to session hijacking, Cross-Site Scripting (XSS), and Cross-Site Request Forgery (CSRF).
To achieve enterprise-grade security, we need to transition away from the simple “single long-lived token” anti-pattern. Instead, we will implement a dual-token architecture consisting of:
- Short-lived stateless Access Tokens (stored in memory).
- Long-lived stateful Refresh Tokens (stored in an HTTP-only, Secure cookie with Rotation and Revocation capabilities).
In this guide, we will build this architecture from scratch using Node.js, Express, Redis (for high-performance token state management), and jsonwebtoken.
The Architecture: Access Tokens vs. Refresh Tokens
The core philosophy of this pattern is separating authorization from session management.
- Access Tokens: These are cryptographically signed JWTs with a very short lifespan (e.g., 15 minutes). Because they are stateless, your API servers can verify them instantly without hitting a database on every request. If an access token leaks, the damage window is extremely small.
- Refresh Tokens: These are opaque, highly randomized strings or tokens with a longer lifespan (e.g., 7 days). They are stored securely on the server side (in Redis or a database) and sent to the client via an
HttpOnlycookie. They can only be used at the/auth/refreshendpoint to mint a new access token.
Why Refresh Token Rotation?
Refresh Token Rotation (RTR) dictates that every time a refresh token is used to get a new access token, the old refresh token is invalidated, and a brand new refresh token is issued.
Client Server (API/Redis)
| |
|--- POST /auth/refresh (RT_1) --->|
| |-- Verify RT_1 exists in Redis
| |-- Delete RT_1 (Revocation)
| |-- Generate AT & RT_2
|<-- Set-Cookie: RT_2 & JSON(AT) --|
If an attacker steals RT_1 and attempts to use it after the legitimate user has already refreshed, the server detects that RT_1 has already been consumed. This is a telltale sign of token theft, triggering an automatic security lockdown that revokes all tokens associated with that user ID.
Project Setup
Let’s initialize our Node.js project and install the necessary dependencies.
mkdir secure-auth-demo
cd secure-auth-demo
npm init -y
npm install express jsonwebtoken cookie-parser redis dotenv uuid
npm install --save-dev nodemon
Ensure your package.json includes a script to run with nodemon for development:
{
"scripts": {
"dev": "nodemon server.js"
}
}
Create a .env file in the root directory:
PORT=3000
ACCESS_TOKEN_SECRET=super_secret_access_key_change_in_production
REFRESH_TOKEN_SECRET=super_secret_refresh_key_change_in_production
ACCESS_TOKEN_EXPIRES_IN=15m
REFRESH_TOKEN_EXPIRES_IN=7d
Setting Up Redis for State Management
Refresh tokens must be stateful so we can revoke them. Redis provides lightning-fast in-memory key-value storage with built-in Time-To-Live (TTL) expiration, making it ideal for token tracking.
Create redisClient.js:
const { createClient } = require('redis');
const client = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379'
});
client.on('error', (err) => console.error('Redis Client Error', err));
(async () => {
await client.connect();
console.log('Connected to Redis');
})();
module.exports = client;
Implementing the Express Server
Let’s build out server.js. We will set up routes for login, token refreshing, and logout, incorporating secure cookie handling and Redis state verification.
require('dotenv').config();
const express = require('express');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');
const { v4: uuidv4 } = require('uuid');
const redisClient = require('./redisClient');
const app = express();
app.use(express.json());
app.use(cookieParser());
const PORT = process.env.PORT || 3000;
// Helper: Generate Access Token
const generateAccessToken = (user) => {
return jwt.sign(
{ userId: user.id, email: user.email },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: process.env.ACCESS_TOKEN_EXPIRES_IN }
);
};
// Helper: Generate Refresh Token (stored in Redis)
const generateRefreshToken = async (userId) => {
const tokenId = uuidv4();
const refreshToken = jwt.sign(
{ userId, tokenId },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: process.env.REFRESH_TOKEN_EXPIRES_IN }
);
// Store token ID in Redis with an expiry matching the token lifetime (7 days in seconds)
const sevenDaysInSeconds = 7 * 24 * 60 * 60;
await redisClient.set(`refresh_token:${userId}:${tokenId}`, 'active', {
EX: sevenDaysInSeconds,
});
return { refreshToken, tokenId };
};
Building Authentication Endpoints
1. Login Endpoint
Upon successful credential verification (mocked here), we issue an access token in the response body and set the refresh token inside an HttpOnly, Secure, SameSite=Strict cookie.
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
// TODO: Validate user credentials against database
if (email !== 'test@example.com' || password !== 'password123') {
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = { id: 'usr_123456', email };
const accessToken = generateAccessToken(user);
const { refreshToken, tokenId } = await generateRefreshToken(user.id);
// Securely set the refresh token in an HTTP-only cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return res.json({
message: 'Login successful',
accessToken,
});
});
2. Refresh Token Endpoint with Rotation and Theft Detection
This is the most critical security endpoint. When hit, we verify the incoming refresh token, check Redis to ensure it hasn’t been used or revoked, and issue a brand new pair.
app.post('/api/auth/refresh', async (req, res) => {
const cookies = req.cookies;
if (!cookies?.refreshToken) {
return res.status(401).json({ error: 'Unauthorized: No refresh token provided' });
}
const oldRefreshToken = cookies.refreshToken;
try {
// 1. Verify JWT signature and structure
const payload = jwt.verify(oldRefreshToken, process.env.REFRESH_TOKEN_SECRET);
const { userId, tokenId } = payload;
// 2. Check Redis for token existence
const redisKey = `refresh_token:${userId}:${tokenId}`;
const tokenStatus = await redisClient.get(redisKey);
if (!tokenStatus) {
// THEFT DETECTION: If token is missing from Redis but valid structurally,
// it means this token was already used (replay attack) or manually revoked!
console.warn(`SECURITY ALERT: Reuse attempt of revoked refresh token for user ${userId}`);
// Revoke ALL refresh tokens for this user immediately
const keys = await redisClient.keys(`refresh_token:${userId}:*`);
if (keys.length > 0) {
await redisClient.del(keys);
}
res.clearCookie('refreshToken');
return res.status(403).json({ error: 'Security violation: Refresh token reuse detected. All sessions terminated.' });
}
// 3. ROTATION: Invalidate the old refresh token immediately
await redisClient.del(redisKey);
// 4. Issue a new Access Token and a new Refresh Token
const user = { id: userId, email: payload.email };
const newAccessToken = generateAccessToken(user);
const { refreshToken: newRefreshToken, tokenId: newTokenId } = await generateRefreshToken(userId);
// 5. Set new refresh token cookie
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return res.json({
accessToken: newAccessToken,
});
} catch (err) {
return res.status(403).json({ error: 'Forbidden: Invalid refresh token' });
});
});
3. Logout Endpoint (Explicit Revocation)
To log a user out, we clear the cookie from the client and purge the specific refresh token from Redis.
app.post('/api/auth/logout', async (req, res) => {
const cookies = req.cookies;
if (!cookies?.refreshToken) {
return res.sendStatus(204); // No content
}
const refreshToken = cookies.refreshToken;
try {
const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
await redisClient.del(`refresh_token:${payload.userId}:${payload.tokenId}`);
} catch (err) {
// Token might be malformed or already expired, ignore and clear cookie anyway
}
res.clearCookie('refreshToken', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
});
return res.json({ message: 'Logged out successfully' });
});
Protecting Routes with Middleware
Now let’s write an Express middleware to protect our API endpoints by verifying the stateless access token passed in the Authorization header.
const verifyAccessToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer <token>
if (!token) {
return res.status(401).json({ error: 'Access token missing' });
}
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Access token expired or invalid' });
}
req.user = user;
next();
});
};
// Protected test route
app.get('/api/protected', verifyAccessToken, (req, res) => {
res.json({
message: 'Access granted to protected resource',
user: req.user,
});
});
app.listen(PORT, () => {
console.log(`Auth server running on port ${PORT}`);
});
Best Practices & Security Checklist
Implementing token rotation and revocation significantly hardens your application, but always keep these production best practices in mind:
Pro Tip: Never store access tokens in
localStorageorsessionStorage. Because JavaScript running in the browser can access these storage mechanisms, any XSS vulnerability will immediately expose your access tokens.
- Always use HTTPS: If
secure: trueis set on cookies without HTTPS in production, the browser will refuse to send the refresh token cookie entirely. - Implement Rate Limiting: Protect your
/api/auth/refreshand/api/auth/loginendpoints with rate limiters (e.g.,express-rate-limit) to prevent brute-force attacks and denial-of-service attempts. - Token Family Tracking (Advanced): For even more granular security, you can group rotated tokens into “families.” If an old token is reused, invalidate the entire family tree, preventing attackers from maintaining access even if they manage to race-condition a refresh cycle.
Conclusion
By pairing stateless access tokens with stateful, rotated refresh tokens stored in secure cookies, you achieve the ideal balance of scalability and security. Your API remains fast (no database lookups for standard requests), while sessions can be instantly terminated, and token theft attempts are detected and neutralized automatically.