Building Secure JWT Authentication in Node.js with Refresh Tokens
Introduction
Authentication is the cornerstone of secure web applications. While JSON Web Tokens (JWT) have become a popular choice for stateless authentication, implementing them securely requires careful consideration of token expiration, storage, and rotation strategies. In this guide, we'll build a robust JWT authentication system with refresh tokens that follows security best practices.
Understanding JWT Security Challenges
JWTs face several security challenges:
- Token Theft: If an access token is compromised, attackers can use it until expiration
- Long Expiration Times: Longer-lived tokens increase security risks
- No Server-Side Revocation: JWTs can't be invalidated server-side without additional mechanisms
- Storage Issues: Improper storage in localStorage exposes tokens to XSS attacks
Refresh tokens solve these issues by allowing short-lived access tokens with a secure renewal mechanism.
Setting Up the Authentication Service
First, let's create our authentication service with the necessary dependencies:
npm install express jsonwebtoken bcryptjs cookie-parser helmet express-rate-limitHere's our base authentication service:
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
class AuthService {
constructor() {
this.accessTokenSecret = process.env.ACCESS_TOKEN_SECRET;
this.refreshTokenSecret = process.env.REFRESH_TOKEN_SECRET;
this.accessTokenExpiry = '15m';
this.refreshTokenExpiry = '7d';
}
async hashPassword(password) {
return await bcrypt.hash(password, 12);
}
async verifyPassword(password, hashedPassword) {
return await bcrypt.compare(password, hashedPassword);
}
generateTokens(payload) {
const accessToken = jwt.sign(
payload,
this.accessTokenSecret,
{ expiresIn: this.accessTokenExpiry }
);
const refreshToken = jwt.sign(
{ ...payload, tokenId: crypto.randomUUID() },
this.refreshTokenSecret,
{ expiresIn: this.refreshTokenExpiry }
);
return { accessToken, refreshToken };
}
verifyAccessToken(token) {
try {
return jwt.verify(token, this.accessTokenSecret);
} catch (error) {
throw new Error('Invalid access token');
}
}
verifyRefreshToken(token) {
try {
return jwt.verify(token, this.refreshTokenSecret);
} catch (error) {
throw new Error('Invalid refresh token');
}
}
}Implementing Secure Token Storage
Store refresh tokens securely using httpOnly cookies and implement a token blacklist:
// Token storage and blacklist management
class TokenManager {
constructor() {
this.tokenBlacklist = new Set(); // In production, use Redis
this.refreshTokens = new Map(); // In production, use database
}
storeRefreshToken(userId, tokenId, token) {
if (!this.refreshTokens.has(userId)) {
this.refreshTokens.set(userId, new Map());
}
this.refreshTokens.get(userId).set(tokenId, {
token,
createdAt: new Date(),
lastUsed: new Date()
});
}
validateRefreshToken(userId, tokenId) {
const userTokens = this.refreshTokens.get(userId);
return userTokens && userTokens.has(tokenId);
}
revokeRefreshToken(userId, tokenId) {
const userTokens = this.refreshTokens.get(userId);
if (userTokens) {
userTokens.delete(tokenId);
}
}
revokeAllUserTokens(userId) {
this.refreshTokens.delete(userId);
}
blacklistToken(token) {
this.tokenBlacklist.add(token);
}
isTokenBlacklisted(token) {
return this.tokenBlacklist.has(token);
}
}Creating Authentication Middleware
Implement middleware to protect routes and handle token validation:
const authMiddleware = (authService, tokenManager) => {
return (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' });
}
if (tokenManager.isTokenBlacklisted(token)) {
return res.status(401).json({ error: 'Token has been revoked' });
}
try {
const decoded = authService.verifyAccessToken(token);
req.user = decoded;
next();
} catch (error) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
};
};Building Authentication Routes
Create comprehensive authentication endpoints with proper security measures:
const express = require('express');
const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const app = express();
const authService = new AuthService();
const tokenManager = new TokenManager();
// Security middleware
app.use(helmet());
app.use(cookieParser());
app.use(express.json({ limit: '10mb' }));
// Rate limiting
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: 'Too many authentication attempts'
});
// Login endpoint
app.post('/auth/login', authLimiter, async (req, res) => {
try {
const { email, password } = req.body;
// Validate user credentials (implement your user lookup)
const user = await findUserByEmail(email);
if (!user || !await authService.verifyPassword(password, user.password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const payload = { userId: user.id, email: user.email };
const { accessToken, refreshToken } = authService.generateTokens(payload);
// Extract tokenId from refresh token
const decoded = authService.verifyRefreshToken(refreshToken);
tokenManager.storeRefreshToken(user.id, decoded.tokenId, refreshToken);
// 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', async (req, res) => {
try {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
const decoded = authService.verifyRefreshToken(refreshToken);
// Validate token exists in storage
if (!tokenManager.validateRefreshToken(decoded.userId, decoded.tokenId)) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
// Generate new token pair
const payload = { userId: decoded.userId, email: decoded.email };
const newTokens = authService.generateTokens(payload);
// Revoke old refresh token and store new one
tokenManager.revokeRefreshToken(decoded.userId, decoded.tokenId);
const newDecoded = authService.verifyRefreshToken(newTokens.refreshToken);
tokenManager.storeRefreshToken(decoded.userId, newDecoded.tokenId, newTokens.refreshToken);
res.cookie('refreshToken', newTokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken: newTokens.accessToken });
} catch (error) {
res.status(403).json({ error: 'Token refresh failed' });
}
});
// Logout endpoint
app.post('/auth/logout', authMiddleware(authService, tokenManager), (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
try {
const decoded = authService.verifyRefreshToken(refreshToken);
tokenManager.revokeRefreshToken(decoded.userId, decoded.tokenId);
} catch (error) {
// Token already invalid
}
}
// Blacklist current access token
const authHeader = req.headers.authorization;
const accessToken = authHeader && authHeader.split(' ')[1];
if (accessToken) {
tokenManager.blacklistToken(accessToken);
}
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});Frontend Implementation
Here's how to handle tokens securely on the frontend:
class AuthClient {
constructor(baseURL) {
this.baseURL = baseURL;
this.accessToken = null;
}
async login(email, password) {
const response = await fetch(`${this.baseURL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password })
});
if (response.ok) {
const data = await response.json();
this.accessToken = data.accessToken;
return data;
}
throw new Error('Login failed');
}
async refreshToken() {
const response = await fetch(`${this.baseURL}/auth/refresh`, {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
this.accessToken = data.accessToken;
return data.accessToken;
}
return null;
}
async apiCall(url, options = {}) {
let response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
}
});
if (response.status === 401) {
// Try to refresh token
if (await this.refreshToken()) {
response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
}
});
}
}
return response;
}
}Security Best Practices
- Use Environment Variables: Store secrets in environment variables, never in code
- Implement Rate Limiting: Prevent brute force attacks on authentication endpoints
- Token Rotation: Issue new refresh tokens on each use for maximum security
- Secure Headers: Use Helmet.js to set security headers
- HTTPS Only: Always use HTTPS in production
- Token Cleanup: Implement cleanup jobs to remove expired tokens
Conclusion
Implementing secure JWT authentication requires attention to multiple security layers: proper token storage, rotation strategies, rate limiting, and secure cookie handling. This implementation provides a solid foundation that you can extend based on your specific requirements. Remember to regularly audit your authentication system and stay updated with security best practices.
Related Posts
Building Secure JWT Authentication with Refresh Token Strategy
Learn how to implement secure JWT authentication with refresh tokens to prevent token hijacking and improve user experience.
Building Secure Authentication with JWT and Refresh Tokens in 2024
Learn how to implement bulletproof JWT authentication with refresh token rotation and security best practices.
Building Secure REST APIs with JWT Authentication: A Complete Implementation Guide
Learn to implement robust JWT authentication in your APIs with proper security practices and real-world code examples.