Building Bulletproof JWT Authentication in Node.js with Refresh Token Rotation
Introduction
JWT authentication is ubiquitous in modern web applications, but many implementations leave critical security gaps. One of the most effective ways to secure JWT-based authentication is through refresh token rotation—a technique that automatically invalidates and replaces refresh tokens on each use. This approach significantly reduces the window of vulnerability if tokens are compromised.
In this guide, we'll build a production-ready authentication system in Node.js that implements refresh token rotation, token blacklisting, and other security best practices.
The Security Problem with Basic JWT
Traditional JWT implementations often suffer from these vulnerabilities:
- Long-lived tokens: If compromised, they remain valid until expiration
- No revocation mechanism: Stolen tokens can't be invalidated
- XSS attacks: Tokens stored in localStorage are vulnerable
- Replay attacks: Intercepted tokens can be reused indefinitely
Refresh token rotation addresses these issues by ensuring tokens have short lifespans and are constantly rotated.
Setting Up the Authentication System
Let's start by setting up our Node.js project with the necessary dependencies:
npm init -y
npm install express jsonwebtoken bcryptjs cookie-parser helmet express-rate-limit
npm install --save-dev nodemonHere's our basic server setup with security middleware:
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const app = express();
// Security middleware
app.use(helmet());
app.use(express.json({ limit: '10mb' }));
app.use(cookieParser());
// Rate limiting for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: 'Too many authentication attempts'
});Implementing Token Generation and Rotation
The core of our secure authentication system lies in proper token management:
class TokenManager {
constructor() {
this.blacklistedTokens = new Set(); // In production, use Redis
this.refreshTokens = new Map(); // Store refresh tokens with metadata
}
generateTokens(userId) {
const payload = { userId, type: 'access' };
// Short-lived access token (15 minutes)
const accessToken = jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: '15m',
issuer: 'your-app-name',
audience: 'your-app-users'
});
// Longer-lived refresh token (7 days)
const refreshPayload = { userId, type: 'refresh', tokenId: this.generateTokenId() };
const refreshToken = jwt.sign(refreshPayload, process.env.REFRESH_SECRET, {
expiresIn: '7d',
issuer: 'your-app-name',
audience: 'your-app-users'
});
// Store refresh token metadata
this.refreshTokens.set(refreshPayload.tokenId, {
userId,
createdAt: new Date(),
lastUsed: new Date()
});
return { accessToken, refreshToken, tokenId: refreshPayload.tokenId };
}
generateTokenId() {
return require('crypto').randomBytes(32).toString('hex');
}
rotateRefreshToken(oldTokenId, userId) {
// Invalidate old refresh token
this.refreshTokens.delete(oldTokenId);
// Generate new tokens
return this.generateTokens(userId);
}
revokeRefreshToken(tokenId) {
this.refreshTokens.delete(tokenId);
}
isTokenBlacklisted(token) {
return this.blacklistedTokens.has(token);
}
blacklistToken(token) {
this.blacklistedTokens.add(token);
// Clean up expired tokens periodically
setTimeout(() => this.blacklistedTokens.delete(token), 15 * 60 * 1000);
}
}Secure Authentication Middleware
Our middleware needs to handle token validation, blacklist checking, and automatic refresh:
const tokenManager = new TokenManager();
const authenticateToken = (req, res, next) => {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
// Check if token is blacklisted
if (tokenManager.isTokenBlacklisted(token)) {
return res.status(401).json({ error: 'Token has been revoked' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(403).json({ error: 'Invalid token' });
}
if (decoded.type !== 'access') {
return res.status(403).json({ error: 'Invalid token type' });
}
req.user = { userId: decoded.userId };
next();
});
};Authentication Endpoints
Now let's implement the login and token refresh endpoints:
// Login endpoint
app.post('/auth/login', authLimiter, async (req, res) => {
try {
const { email, password } = req.body;
// Validate user credentials (implement your user lookup logic)
const user = await findUserByEmail(email);
if (!user || !await bcrypt.compare(password, user.hashedPassword)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const { accessToken, refreshToken } = tokenManager.generateTokens(user.id);
// Set refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({
accessToken,
user: { id: user.id, email: user.email }
});
} catch (error) {
res.status(500).json({ error: 'Authentication failed' });
}
});
// Token refresh endpoint
app.post('/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
jwt.verify(refreshToken, process.env.REFRESH_SECRET, (err, decoded) => {
if (err) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
// Check if refresh token exists in our store
if (!tokenManager.refreshTokens.has(decoded.tokenId)) {
return res.status(403).json({ error: 'Refresh token revoked' });
}
// Rotate tokens
const { accessToken, refreshToken: newRefreshToken } =
tokenManager.rotateRefreshToken(decoded.tokenId, decoded.userId);
// Update refresh token cookie
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken });
});
});Logout and Token Revocation
Proper logout handling is crucial for security:
// Logout endpoint
app.post('/auth/logout', authenticateToken, (req, res) => {
const refreshToken = req.cookies.refreshToken;
const accessToken = req.headers.authorization?.split(' ')[1];
// Blacklist current access token
if (accessToken) {
tokenManager.blacklistToken(accessToken);
}
// Revoke refresh token
if (refreshToken) {
jwt.verify(refreshToken, process.env.REFRESH_SECRET, (err, decoded) => {
if (!err && decoded.tokenId) {
tokenManager.revokeRefreshToken(decoded.tokenId);
}
});
}
// Clear refresh token cookie
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});Client-Side Implementation
On the frontend, implement automatic token refresh:
class AuthService {
constructor() {
this.accessToken = null;
this.refreshPromise = null;
}
async apiCall(url, options = {}) {
let token = this.accessToken;
// Try request with current token
let response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
}
});
// If token expired, refresh and retry
if (response.status === 401) {
await this.refreshToken();
response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
}
});
}
return response;
}
async refreshToken() {
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = fetch('/auth/refresh', {
method: 'POST',
credentials: 'include'
}).then(res => res.json())
.then(data => {
this.accessToken = data.accessToken;
this.refreshPromise = null;
});
return this.refreshPromise;
}
}Best Practices and Security Considerations
To maximize security, follow these additional practices:
- Use environment variables: Never hardcode secrets
- Implement proper CORS: Restrict origins in production
- Add request logging: Monitor authentication attempts
- Use Redis for production: Replace in-memory storage
- Implement device tracking: Detect suspicious login patterns
- Add 2FA support: Extra security layer for sensitive accounts
Conclusion
Implementing refresh token rotation significantly enhances your application's security posture. By automatically invalidating and replacing tokens, you minimize the risk of token theft while maintaining a seamless user experience. Remember to adapt this implementation to your specific needs and always keep security best practices in mind.
This robust authentication system provides a solid foundation for secure applications while remaining scalable and maintainable. The key is balancing security with usability—and refresh token rotation achieves exactly that.
Related Posts
Building Secure REST APIs: A Complete Authentication and Authorization Guide
Master API security with JWT tokens, role-based access control, and essential security practices for production applications.
Building Secure Authentication with JWT and OAuth 2.0: A Complete Implementation Guide
Learn to implement bulletproof authentication using JWT tokens and OAuth 2.0 with practical security measures and code examples.
Building Secure User Authentication with JWT and Refresh Tokens in Node.js
Learn to implement bulletproof JWT authentication with refresh token rotation and security best practices in Node.js applications.