Building Secure JWT Authentication with Refresh Tokens in Node.js
Introduction
JWT (JSON Web Tokens) have become the go-to solution for stateless authentication in modern web applications. However, many developers implement JWT authentication incorrectly, leaving their applications vulnerable to token theft and abuse. In this comprehensive guide, we'll build a secure JWT authentication system with refresh tokens that addresses common security pitfalls.
The Problem with Basic JWT Implementation
Most JWT tutorials show you how to create a token that lasts for hours or even days. This approach has serious security implications:
- Long-lived tokens increase the window of opportunity for attackers if tokens are compromised
- No way to revoke tokens before they naturally expire
- Storing tokens in localStorage makes them vulnerable to XSS attacks
- No protection against CSRF attacks when tokens are stored in cookies
The Refresh Token Strategy
The solution is implementing a dual-token system:
- Access Token: Short-lived (15 minutes), contains user permissions
- Refresh Token: Long-lived (7 days), used only to generate new access tokens
This approach minimizes the attack window while maintaining a smooth user experience.
Setting Up the Environment
First, let's set 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 -D nodemonCreating the Authentication Service
Let's start by creating a robust authentication service:
// auth/authService.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
class AuthService {
constructor() {
this.accessTokenSecret = process.env.JWT_ACCESS_SECRET;
this.refreshTokenSecret = process.env.JWT_REFRESH_SECRET;
this.accessTokenExpiry = '15m';
this.refreshTokenExpiry = '7d';
// In production, use Redis or database
this.refreshTokens = new Set();
}
generateTokens(payload) {
const accessToken = jwt.sign(payload, this.accessTokenSecret, {
expiresIn: this.accessTokenExpiry,
issuer: 'your-app-name',
audience: 'your-app-users'
});
const refreshToken = jwt.sign(payload, this.refreshTokenSecret, {
expiresIn: this.refreshTokenExpiry,
issuer: 'your-app-name',
audience: 'your-app-users'
});
this.refreshTokens.add(refreshToken);
return { accessToken, refreshToken };
}
verifyAccessToken(token) {
try {
return jwt.verify(token, this.accessTokenSecret);
} catch (error) {
throw new Error('Invalid access token');
}
}
verifyRefreshToken(token) {
if (!this.refreshTokens.has(token)) {
throw new Error('Invalid refresh token');
}
try {
return jwt.verify(token, this.refreshTokenSecret);
} catch (error) {
this.refreshTokens.delete(token);
throw new Error('Invalid refresh token');
}
}
revokeRefreshToken(token) {
this.refreshTokens.delete(token);
}
}
module.exports = new AuthService();Implementing Secure Middleware
Create middleware to protect routes and handle token validation:
// middleware/authMiddleware.js
const authService = require('../auth/authService');
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',
code: 'NO_TOKEN'
});
}
try {
const decoded = authService.verifyAccessToken(token);
req.user = decoded;
next();
} catch (error) {
return res.status(403).json({
error: 'Invalid or expired token',
code: 'INVALID_TOKEN'
});
}
};
module.exports = { authenticateToken };Building the Authentication Routes
Now let's create the complete authentication flow:
// routes/auth.js
const express = require('express');
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');
const authService = require('../auth/authService');
const router = express.Router();
// Rate limiting for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: { error: 'Too many authentication attempts' }
});
// Login endpoint
router.post('/login', authLimiter, async (req, res) => {
try {
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({
error: 'Email and password required'
});
}
// Find user (replace with your database query)
const user = await findUserByEmail(email);
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({
error: 'Invalid credentials'
});
}
// Generate tokens
const payload = {
userId: user.id,
email: user.email,
role: user.role
};
const { accessToken, refreshToken } = authService.generateTokens(payload);
// Set secure HTTP-only cookie for refresh token
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, role: user.role }
});
} catch (error) {
res.status(500).json({ error: 'Authentication failed' });
}
});
// Token refresh endpoint
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
try {
const decoded = authService.verifyRefreshToken(refreshToken);
// Generate new tokens
const payload = {
userId: decoded.userId,
email: decoded.email,
role: decoded.role
};
const { accessToken, refreshToken: newRefreshToken } =
authService.generateTokens(payload);
// Revoke old refresh token
authService.revokeRefreshToken(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 });
} catch (error) {
res.status(403).json({ error: 'Invalid refresh token' });
}
});
// Logout endpoint
router.post('/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
authService.revokeRefreshToken(refreshToken);
}
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});
module.exports = router;Security Best Practices Implemented
Our implementation includes several critical security measures:
- Short-lived access tokens: Minimize exposure window
- HTTP-only cookies: Protect refresh tokens from XSS
- Rate limiting: Prevent brute force attacks
- Token rotation: Generate new refresh tokens on each use
- Proper token validation: Verify issuer and audience claims
- Secure cookie settings: Use Secure and SameSite flags
Frontend Integration
On the frontend, implement automatic token refresh:
// Frontend token management
class TokenManager {
constructor() {
this.accessToken = localStorage.getItem('accessToken');
}
async makeAuthenticatedRequest(url, options = {}) {
let response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
},
credentials: 'include' // Include cookies
});
if (response.status === 403) {
// Try to refresh token
const refreshed = await this.refreshToken();
if (refreshed) {
// Retry original request
response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
},
credentials: 'include'
});
}
}
return response;
}
async refreshToken() {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
const { accessToken } = await response.json();
this.accessToken = accessToken;
localStorage.setItem('accessToken', accessToken);
return true;
}
} catch (error) {
console.error('Token refresh failed:', error);
}
return false;
}
}Conclusion
Implementing secure JWT authentication requires more than just signing tokens. By using refresh tokens, proper cookie settings, rate limiting, and token rotation, you can build a robust authentication system that protects against common attack vectors. Remember to always store sensitive secrets in environment variables and consider using Redis or a database for refresh token storage in production environments.
This implementation provides a solid foundation for secure authentication that you can adapt to your specific requirements while maintaining security best practices.
Related Posts
Building Secure JWT Authentication in Node.js with Refresh Tokens
Learn to implement robust JWT authentication with refresh tokens, secure storage, and proper token rotation in Node.js applications.
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.