Building Bulletproof Authentication with JWT and Refresh Token Strategy
Introduction
Authentication is the backbone of any secure web application, yet many developers implement JWT tokens incorrectly, leaving their applications vulnerable to token theft, replay attacks, and session hijacking. Today, we'll explore how to build a robust authentication system using JWT access tokens paired with refresh tokens, implementing security best practices that protect against common attack vectors.
Understanding the Security Problem
Single JWT tokens stored in localStorage are vulnerable to XSS attacks, while long-lived tokens increase the risk if compromised. The solution? A dual-token approach with short-lived access tokens and secure refresh tokens.
Key Security Principles
- Principle of Least Privilege: Access tokens should have minimal lifetime
- Defense in Depth: Multiple layers of security
- Secure Storage: Proper token storage mechanisms
- Token Rotation: Regular refresh to minimize exposure
Implementing the Backend (Node.js/Express)
Let's start with a secure backend implementation that handles both access and refresh tokens:
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
// Token configuration
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
// Generate token pair
function generateTokens(user) {
const payload = {
userId: user.id,
email: user.email,
role: user.role
};
const accessToken = jwt.sign(payload, ACCESS_TOKEN_SECRET, {
expiresIn: ACCESS_TOKEN_EXPIRY,
issuer: 'your-app-name',
audience: 'your-app-users'
});
const refreshToken = jwt.sign(
{ userId: user.id, tokenVersion: user.tokenVersion },
REFRESH_TOKEN_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
return { accessToken, refreshToken };
}
// Login endpoint
app.post('/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
// Validate user credentials
const user = await User.findOne({ email });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate tokens
const { accessToken, refreshToken } = generateTokens(user);
// 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
});
// Store refresh token hash in database
const refreshTokenHash = crypto
.createHash('sha256')
.update(refreshToken)
.digest('hex');
await User.updateOne(
{ _id: user.id },
{ $push: { refreshTokens: refreshTokenHash } }
);
res.json({ accessToken, user: { id: user.id, email: user.email } });
} catch (error) {
res.status(500).json({ error: 'Authentication failed' });
}
});Refresh Token Implementation
The refresh endpoint handles token rotation securely:
// Refresh token endpoint
app.post('/auth/refresh', async (req, res) => {
try {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
// Verify refresh token
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
const user = await User.findById(decoded.userId);
if (!user || decoded.tokenVersion !== user.tokenVersion) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Check if refresh token exists in database
const tokenHash = crypto
.createHash('sha256')
.update(refreshToken)
.digest('hex');
if (!user.refreshTokens.includes(tokenHash)) {
return res.status(401).json({ error: 'Token not found' });
}
// Generate new token pair
const tokens = generateTokens(user);
// Remove old refresh token and add new one
await User.updateOne(
{ _id: user.id },
{
$pull: { refreshTokens: tokenHash },
$push: { refreshTokens: crypto.createHash('sha256').update(tokens.refreshToken).digest('hex') }
}
);
// Set new refresh token cookie
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken: tokens.accessToken });
} catch (error) {
res.status(401).json({ error: 'Token refresh failed' });
}
});Frontend Implementation (React)
On the frontend, implement automatic token refresh with an Axios interceptor:
import axios from 'axios';
class AuthService {
constructor() {
this.setupInterceptors();
}
setupInterceptors() {
// Request interceptor to add access token
axios.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for token refresh
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const response = await axios.post('/auth/refresh', {}, {
withCredentials: true
});
const { accessToken } = response.data;
localStorage.setItem('accessToken', accessToken);
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return axios(originalRequest);
} catch (refreshError) {
// Refresh failed, redirect to login
localStorage.removeItem('accessToken');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
}
}Additional Security Measures
Token Versioning
Implement token versioning to invalidate all user sessions when needed:
// Logout all devices
app.post('/auth/logout-all', authenticateToken, async (req, res) => {
try {
await User.updateOne(
{ _id: req.user.userId },
{
$inc: { tokenVersion: 1 },
$set: { refreshTokens: [] }
}
);
res.clearCookie('refreshToken');
res.json({ message: 'Logged out from all devices' });
} catch (error) {
res.status(500).json({ error: 'Logout failed' });
}
});Rate Limiting
Implement rate limiting on authentication endpoints:
const rateLimit = require('express-rate-limit');
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: 'Too many authentication attempts',
standardHeaders: true,
legacyHeaders: false
});
app.use('/auth/login', authLimiter);Conclusion
Implementing secure JWT authentication requires careful consideration of token storage, rotation, and validation. By using short-lived access tokens with secure refresh tokens, implementing proper CSRF protection, and adding rate limiting, you create a robust authentication system that protects against common attack vectors.
Remember to regularly audit your authentication implementation, keep dependencies updated, and monitor for suspicious authentication patterns. Security is an ongoing process, not a one-time implementation.
Related Posts
Building Secure Authentication with JWT and OAuth 2.0 in Node.js Applications
Learn how to implement robust authentication using JWT tokens and OAuth 2.0 in Node.js applications with security best practices.
Building Secure Authentication with JWT in Node.js: Best Practices for 2024
Learn how to implement bulletproof JWT authentication in Node.js with modern security practices and real-world examples.
Building Secure JWT Authentication with Refresh Tokens in Node.js
Learn to implement bulletproof JWT authentication with refresh token rotation and security best practices for production apps.