Building Secure Authentication with JWT and Refresh Tokens: A Complete Guide
Introduction
Authentication is the backbone of most web applications, yet it's often implemented insecurely. JSON Web Tokens (JWT) have become the de facto standard for stateless authentication, but many developers fall into common security traps. In this comprehensive guide, we'll build a secure authentication system using JWT access tokens paired with refresh tokens, addressing critical security concerns along the way.
Understanding JWT Security Challenges
Before diving into implementation, let's understand the key security challenges with JWT authentication:
- Token Expiration: Long-lived tokens increase security risks if compromised
- Token Storage: Storing tokens in localStorage makes them vulnerable to XSS attacks
- Token Revocation: Pure stateless JWTs cannot be revoked server-side
- Replay Attacks: Intercepted tokens can be reused maliciously
The solution? A dual-token approach with short-lived access tokens and secure refresh tokens.
Architecture Overview
Our secure authentication system will use:
- Access Token: Short-lived JWT (15-30 minutes) for API requests
- Refresh Token: Long-lived, stored securely server-side for obtaining new access tokens
- HTTP-only Cookies: For storing refresh tokens, preventing XSS access
- CSRF Protection: Additional security layer for state-changing operations
Backend Implementation (Node.js + Express)
Let's start with the server-side implementation:
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const cookieParser = require('cookie-parser');
const crypto = require('crypto');
const app = express();
app.use(express.json());
app.use(cookieParser());
// In-memory store for refresh tokens (use Redis in production)
const refreshTokens = new Map();
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 tokens
function generateTokens(userId) {
const payload = { userId, type: 'access' };
const accessToken = jwt.sign(payload, ACCESS_TOKEN_SECRET, {
expiresIn: ACCESS_TOKEN_EXPIRY,
issuer: 'your-app-name',
audience: 'your-app-users'
});
const refreshToken = crypto.randomBytes(64).toString('hex');
// Store refresh token with expiry
refreshTokens.set(refreshToken, {
userId,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
});
return { accessToken, refreshToken };
}Login Endpoint with Security Best Practices
app.post('/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
// Input validation
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
// Find user (pseudo-code - replace with your DB logic)
const user = await findUserByEmail(email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password
const validPassword = await bcrypt.compare(password, user.hashedPassword);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate tokens
const { accessToken, refreshToken } = generateTokens(user.id);
// Set refresh token as 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 access token and user info
res.json({
accessToken,
user: {
id: user.id,
email: user.email,
name: user.name
}
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});Token Refresh Mechanism
app.post('/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token not found' });
}
const tokenData = refreshTokens.get(refreshToken);
if (!tokenData || tokenData.expiresAt < new Date()) {
// Clean up expired token
refreshTokens.delete(refreshToken);
return res.status(403).json({ error: 'Invalid or expired refresh token' });
}
// Generate new tokens
const { accessToken, refreshToken: newRefreshToken } = generateTokens(tokenData.userId);
// Revoke old refresh token (rotation)
refreshTokens.delete(refreshToken);
// 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
});
res.json({ accessToken });
});Authentication Middleware
function authenticateToken(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 required' });
}
jwt.verify(token, ACCESS_TOKEN_SECRET, {
issuer: 'your-app-name',
audience: 'your-app-users'
}, (err, decoded) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.user = { userId: decoded.userId };
next();
});
}
// Protected route example
app.get('/api/profile', authenticateToken, (req, res) => {
res.json({ message: `Hello user ${req.user.userId}` });
});Frontend Implementation Best Practices
On the client side, implement automatic token refresh:
class AuthService {
constructor() {
this.accessToken = localStorage.getItem('accessToken');
this.setupInterceptors();
}
setupInterceptors() {
// Add token to requests
axios.interceptors.request.use(config => {
if (this.accessToken) {
config.headers.Authorization = `Bearer ${this.accessToken}`;
}
return config;
});
// Handle token refresh on 401
axios.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401) {
const refreshed = await this.refreshToken();
if (refreshed) {
// Retry original request
return axios.request(error.config);
}
}
return Promise.reject(error);
}
);
}
async refreshToken() {
try {
const response = await axios.post('/auth/refresh', {}, {
withCredentials: true // Send cookies
});
this.accessToken = response.data.accessToken;
localStorage.setItem('accessToken', this.accessToken);
return true;
} catch (error) {
this.logout();
return false;
}
}
logout() {
this.accessToken = null;
localStorage.removeItem('accessToken');
// Redirect to login
}
}Additional Security Measures
Enhance your authentication system with these security practices:
- Rate Limiting: Implement login attempt limits to prevent brute force attacks
- Account Lockout: Temporarily lock accounts after failed attempts
- Token Binding: Bind tokens to specific client characteristics
- Audit Logging: Log all authentication events for monitoring
- HTTPS Only: Never transmit tokens over unencrypted connections
Conclusion
Implementing secure JWT authentication requires careful consideration of token lifecycle, storage, and rotation. The dual-token approach with HTTP-only cookies for refresh tokens and short-lived access tokens provides a robust security foundation. Remember to regularly audit your authentication system, keep dependencies updated, and follow security best practices. This implementation provides a solid starting point for secure authentication in your applications while remaining scalable and maintainable.
Related Posts
Building Secure Authentication with JWT: Best Practices for 2024
Learn how to implement bulletproof JWT authentication with proper security measures to protect your applications from common attacks.
Implementing JWT Authentication with Role-Based Access Control in Node.js
Master secure JWT authentication with role-based access control in Node.js applications using industry best practices.
Building Secure JWT Authentication with Refresh Tokens in Node.js
Learn how to implement bulletproof JWT authentication with refresh tokens to prevent security vulnerabilities in your Node.js applications.