Building Secure JWT Authentication with Refresh Tokens in Node.js
Introduction
JWT authentication is everywhere in modern web applications, but most implementations are vulnerable to token theft and replay attacks. As developers, we often focus on getting authentication working quickly, but overlook critical security measures that can make or break our applications in production.
Today, I'll walk you through building a robust JWT authentication system with refresh token rotation, secure storage practices, and proper token lifecycle management that you can confidently deploy to production.
The Security Problem with Basic JWT
Standard JWT implementations typically store access tokens in localStorage or cookies without proper security measures. This creates several vulnerabilities:
- XSS Attacks: Malicious scripts can steal tokens from localStorage
- Token Theft: Long-lived tokens provide extended access if compromised
- Session Management: No way to revoke sessions server-side
- Replay Attacks: Stolen tokens can be used indefinitely until expiration
Implementing Secure JWT with Refresh Tokens
Let's build a secure authentication system using Node.js, Express, and proper security practices.
Project Setup
npm init -y
npm install express jsonwebtoken bcryptjs cookie-parser helmet cors dotenv
npm install -D nodemonEnvironment Configuration
# .env
JWT_ACCESS_SECRET=your-super-secure-access-secret-key-here
JWT_REFRESH_SECRET=your-different-refresh-secret-key-here
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
NODE_ENV=productionToken Service Implementation
// services/tokenService.js
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
class TokenService {
generateTokens(payload) {
const accessToken = jwt.sign(
payload,
process.env.JWT_ACCESS_SECRET,
{ expiresIn: process.env.ACCESS_TOKEN_EXPIRY }
);
const refreshToken = jwt.sign(
{ ...payload, tokenVersion: crypto.randomUUID() },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: process.env.REFRESH_TOKEN_EXPIRY }
);
return { accessToken, refreshToken };
}
verifyAccessToken(token) {
try {
return jwt.verify(token, process.env.JWT_ACCESS_SECRET);
} catch (error) {
throw new Error('Invalid access token');
}
}
verifyRefreshToken(token) {
try {
return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
} catch (error) {
throw new Error('Invalid refresh token');
}
}
}
module.exports = new TokenService();Secure Cookie Configuration
// middleware/authMiddleware.js
const tokenService = require('../services/tokenService');
const setSecureCookie = (res, name, value, maxAge) => {
res.cookie(name, value, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: maxAge,
path: '/'
});
};
const authenticateToken = async (req, res, next) => {
try {
const accessToken = req.cookies.accessToken;
if (!accessToken) {
return res.status(401).json({ error: 'Access token required' });
}
const decoded = tokenService.verifyAccessToken(accessToken);
req.user = decoded;
next();
} catch (error) {
// Try to refresh token automatically
return handleTokenRefresh(req, res, next);
}
};
const handleTokenRefresh = async (req, res, next) => {
try {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
const decoded = tokenService.verifyRefreshToken(refreshToken);
// Generate new token pair
const tokens = tokenService.generateTokens({
userId: decoded.userId,
email: decoded.email
});
// Set new secure cookies
setSecureCookie(res, 'accessToken', tokens.accessToken, 15 * 60 * 1000);
setSecureCookie(res, 'refreshToken', tokens.refreshToken, 7 * 24 * 60 * 60 * 1000);
req.user = decoded;
next();
} catch (error) {
res.clearCookie('accessToken');
res.clearCookie('refreshToken');
return res.status(401).json({ error: 'Authentication failed' });
}
};
module.exports = { authenticateToken, setSecureCookie };Authentication Routes
// routes/auth.js
const express = require('express');
const bcrypt = require('bcryptjs');
const tokenService = require('../services/tokenService');
const { setSecureCookie } = require('../middleware/authMiddleware');
const router = express.Router();
// Login endpoint
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
// Validate user (replace with your user validation logic)
const user = await User.findOne({ email });
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate tokens
const tokens = tokenService.generateTokens({
userId: user.id,
email: user.email
});
// Set secure cookies
setSecureCookie(res, 'accessToken', tokens.accessToken, 15 * 60 * 1000);
setSecureCookie(res, 'refreshToken', tokens.refreshToken, 7 * 24 * 60 * 60 * 1000);
res.json({
message: 'Login successful',
user: { id: user.id, email: user.email }
});
} catch (error) {
res.status(500).json({ error: 'Login failed' });
}
});
// Logout endpoint
router.post('/logout', (req, res) => {
res.clearCookie('accessToken');
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});
// Token refresh endpoint
router.post('/refresh', async (req, res) => {
try {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
const decoded = tokenService.verifyRefreshToken(refreshToken);
const tokens = tokenService.generateTokens({
userId: decoded.userId,
email: decoded.email
});
setSecureCookie(res, 'accessToken', tokens.accessToken, 15 * 60 * 1000);
setSecureCookie(res, 'refreshToken', tokens.refreshToken, 7 * 24 * 60 * 60 * 1000);
res.json({ message: 'Tokens refreshed' });
} catch (error) {
res.clearCookie('accessToken');
res.clearCookie('refreshToken');
res.status(401).json({ error: 'Token refresh failed' });
}
});
module.exports = router;Frontend Implementation
On the frontend, implement automatic token refresh and proper error handling:
// Frontend API client
class ApiClient {
async makeRequest(url, options = {}) {
try {
const response = await fetch(url, {
...options,
credentials: 'include', // Include cookies
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
if (response.status === 401) {
// Try to refresh token
const refreshResponse = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (refreshResponse.ok) {
// Retry original request
return fetch(url, { ...options, credentials: 'include' });
} else {
// Redirect to login
window.location.href = '/login';
}
}
return response;
} catch (error) {
throw new Error('Network error');
}
}
}
const apiClient = new ApiClient();Security Best Practices Implemented
- Short-lived Access Tokens: 15-minute expiry reduces exposure window
- Secure Cookies: HttpOnly, Secure, SameSite protection
- Token Rotation: New refresh token on each use prevents replay attacks
- Automatic Refresh: Seamless user experience with background token renewal
- Proper Cleanup: Clear tokens on logout and authentication failures
Conclusion
This implementation provides enterprise-grade JWT security while maintaining good user experience. The automatic token refresh ensures users stay logged in seamlessly, while the security measures protect against common attack vectors.
Remember to regularly audit your authentication system, implement rate limiting, and consider additional security measures like device tracking and anomaly detection for high-security applications.
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 Bulletproof Authentication with JWT and Refresh Token Strategy
Learn how to implement secure JWT authentication with refresh tokens to protect against common vulnerabilities and attacks.