Building Secure Authentication with JWT and Refresh Tokens in 2024
Introduction
Authentication remains one of the most critical aspects of web application security. While JSON Web Tokens (JWT) have become the de facto standard for stateless authentication, implementing them securely requires careful consideration of token lifecycle management, storage, and refresh mechanisms. In this guide, we'll build a robust authentication system that addresses common security vulnerabilities while maintaining excellent user experience.
Understanding JWT Security Challenges
JWTs offer excellent scalability benefits, but they come with inherent security challenges:
- Token Theft: If stolen, JWTs can be used until expiration
- Storage Vulnerabilities: Client-side storage options each have security trade-offs
- Logout Complications: Stateless tokens can't be easily invalidated
- Long-lived Tokens: Balancing security with user experience
The solution lies in implementing a dual-token strategy with proper security measures.
Implementing Secure JWT Authentication
Backend Implementation (Node.js/Express)
Let's start with a secure backend implementation:
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
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';
}
generateTokens(payload) {
const accessToken = jwt.sign(
payload,
this.accessTokenSecret,
{ expiresIn: this.accessTokenExpiry }
);
const refreshToken = jwt.sign(
payload,
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');
}
}
}Refresh Token Rotation
Implement refresh token rotation to minimize the impact of token theft:
// Refresh token endpoint
app.post('/auth/refresh', async (req, res) => {
try {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token provided' });
}
// Verify the refresh token
const decoded = authService.verifyRefreshToken(refreshToken);
// Check if token exists in database (whitelist approach)
const storedToken = await RefreshToken.findOne({
token: refreshToken,
userId: decoded.userId,
isActive: true
});
if (!storedToken) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Generate new token pair
const tokens = authService.generateTokens({
userId: decoded.userId,
email: decoded.email
});
// Invalidate old refresh token
await RefreshToken.updateOne(
{ _id: storedToken._id },
{ isActive: false }
);
// Store new refresh token
await RefreshToken.create({
token: tokens.refreshToken,
userId: decoded.userId,
isActive: true,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
// Set secure cookies
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)
Create an authentication context with automatic token refresh:
import React, { createContext, useContext, useEffect, useState } from 'react';
import axios from 'axios';
const AuthContext = createContext();
export const useAuth = () => useContext(AuthContext);
export const AuthProvider = ({ children }) => {
const [accessToken, setAccessToken] = useState(null);
const [loading, setLoading] = useState(true);
// Axios interceptor for automatic token refresh
useEffect(() => {
const requestInterceptor = axios.interceptors.request.use(
(config) => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
}
);
const responseInterceptor = axios.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
try {
const response = await axios.post('/auth/refresh', {}, {
withCredentials: true
});
const newAccessToken = response.data.accessToken;
setAccessToken(newAccessToken);
error.config.headers.Authorization = `Bearer ${newAccessToken}`;
return axios.request(error.config);
} catch (refreshError) {
setAccessToken(null);
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
return () => {
axios.interceptors.request.eject(requestInterceptor);
axios.interceptors.response.eject(responseInterceptor);
};
}, [accessToken]);
const login = async (credentials) => {
try {
const response = await axios.post('/auth/login', credentials, {
withCredentials: true
});
setAccessToken(response.data.accessToken);
return true;
} catch (error) {
throw new Error(error.response?.data?.error || 'Login failed');
}
};
const logout = async () => {
try {
await axios.post('/auth/logout', {}, { withCredentials: true });
} finally {
setAccessToken(null);
}
};
return (
{children}
);
};Security Best Practices
Token Storage
- Access Tokens: Store in memory only (component state/context)
- Refresh Tokens: Use httpOnly cookies with secure flags
- Never store tokens in localStorage for production applications
Additional Security Measures
// Rate limiting for auth endpoints
const rateLimit = require('express-rate-limit');
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // limit each IP to 5 requests per windowMs
message: 'Too many authentication attempts'
});
app.use('/auth/login', authLimiter);
app.use('/auth/refresh', rateLimit({
windowMs: 15 * 60 * 1000,
max: 10
}));Testing Your Implementation
Always test your authentication flow thoroughly:
- Token expiration and refresh scenarios
- Concurrent requests during token refresh
- Logout functionality and token invalidation
- Rate limiting effectiveness
- XSS and CSRF protection measures
Conclusion
Secure JWT authentication requires more than just signing tokens. By implementing refresh token rotation, proper storage mechanisms, and comprehensive security measures, you can build an authentication system that's both secure and user-friendly. Remember to regularly audit your implementation and stay updated with the latest security recommendations from OWASP and other security organizations.
Related Posts
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.
Implementing Multi-Factor Authentication with JWT and Time-Based OTP in Node.js
Learn to build secure MFA using JWT tokens and time-based one-time passwords with practical Node.js implementation.
Implementing OAuth 2.0 Authentication with PKCE in Modern Web Applications
Learn how to implement secure OAuth 2.0 authentication with PKCE flow to protect your web applications from common security vulnerabilities.