Building Secure JWT Authentication with Refresh Tokens in Node.js
Introduction
JSON Web Tokens (JWT) have become the de facto standard for API authentication, but implementing them securely requires more than just signing a token and calling it a day. Many developers make critical security mistakes that can lead to token theft, replay attacks, and unauthorized access. In this comprehensive guide, we'll build a robust JWT authentication system with refresh tokens that addresses common security pitfalls.
Understanding the Security Challenges
Before diving into implementation, let's understand why basic JWT authentication isn't enough:
- Long-lived tokens: If access tokens have long expiration times, stolen tokens remain valid for extended periods
- No revocation mechanism: JWTs are stateless, making it difficult to invalidate compromised tokens
- XSS vulnerabilities: Storing tokens in localStorage exposes them to cross-site scripting attacks
- CSRF attacks: Improper cookie handling can lead to cross-site request forgery
The Dual-Token Strategy
Our solution uses two types of tokens:
- Access Token: Short-lived (15 minutes), contains user claims, stored in memory
- Refresh Token: Long-lived (7 days), stored securely, used only to generate new access tokens
Setting Up the Authentication Service
First, let's create our authentication service with proper token generation:
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
class AuthService {
constructor() {
this.accessTokenSecret = process.env.JWT_ACCESS_SECRET;
this.refreshTokenSecret = process.env.JWT_REFRESH_SECRET;
this.accessTokenExpiry = '15m';
this.refreshTokenExpiry = '7d';
}
generateTokenPair(userId, userRole) {
const payload = {
userId,
role: userRole,
type: 'access'
};
const accessToken = jwt.sign(payload, this.accessTokenSecret, {
expiresIn: this.accessTokenExpiry,
issuer: 'your-app-name',
audience: 'your-app-users'
});
const refreshPayload = {
userId,
type: 'refresh',
tokenId: crypto.randomUUID()
};
const refreshToken = jwt.sign(refreshPayload, this.refreshTokenSecret, {
expiresIn: this.refreshTokenExpiry,
issuer: 'your-app-name',
audience: 'your-app-users'
});
return { accessToken, refreshToken, tokenId: refreshPayload.tokenId };
}
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');
}
}
}Implementing Secure Token Storage
We need a mechanism to track valid refresh tokens and enable revocation:
const Redis = require('redis');
class TokenStore {
constructor() {
this.redis = Redis.createClient({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT
});
}
async storeRefreshToken(userId, tokenId, expiresIn) {
const key = `refresh_token:${userId}:${tokenId}`;
await this.redis.setex(key, expiresIn, 'valid');
}
async isRefreshTokenValid(userId, tokenId) {
const key = `refresh_token:${userId}:${tokenId}`;
const result = await this.redis.get(key);
return result === 'valid';
}
async revokeRefreshToken(userId, tokenId) {
const key = `refresh_token:${userId}:${tokenId}`;
await this.redis.del(key);
}
async revokeAllUserTokens(userId) {
const pattern = `refresh_token:${userId}:*`;
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
}
}
}Creating Authentication Middleware
Now let's implement middleware that validates tokens and handles automatic refresh:
const authMiddleware = (authService, tokenStore) => {
return async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No valid authorization header' });
}
const token = authHeader.substring(7);
const decoded = authService.verifyAccessToken(token);
// Attach user info to request
req.user = {
userId: decoded.userId,
role: decoded.role
};
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Token expired',
code: 'TOKEN_EXPIRED'
});
}
return res.status(401).json({ error: 'Invalid token' });
}
};
};Implementing Login and Token Refresh Endpoints
Here's how to implement secure login and refresh endpoints:
// Login endpoint
app.post('/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
// Validate user credentials (implement your user lookup logic)
const user = await User.findByEmail(email);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate token pair
const { accessToken, refreshToken, tokenId } = authService.generateTokenPair(
user.id,
user.role
);
// Store refresh token
await tokenStore.storeRefreshToken(user.id, tokenId, 604800); // 7 days
// 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
});
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
app.post('/auth/refresh', async (req, res) => {
try {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token provided' });
}
const decoded = authService.verifyRefreshToken(refreshToken);
// Verify token exists in store
const isValid = await tokenStore.isRefreshTokenValid(
decoded.userId,
decoded.tokenId
);
if (!isValid) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Get user data
const user = await User.findById(decoded.userId);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
// Generate new token pair
const {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
tokenId: newTokenId
} = authService.generateTokenPair(user.id, user.role);
// Revoke old refresh token and store new one
await tokenStore.revokeRefreshToken(decoded.userId, decoded.tokenId);
await tokenStore.storeRefreshToken(user.id, newTokenId, 604800);
// Update 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: newAccessToken });
} catch (error) {
res.status(401).json({ error: 'Token refresh failed' });
}
});Frontend Integration Best Practices
On the frontend, implement automatic token refresh logic:
class ApiClient {
constructor() {
this.accessToken = null;
this.isRefreshing = false;
this.refreshPromise = null;
}
async request(url, options = {}) {
let response = await this.makeRequest(url, options);
if (response.status === 401 && response.data?.code === 'TOKEN_EXPIRED') {
const refreshed = await this.refreshAccessToken();
if (refreshed) {
response = await this.makeRequest(url, options);
}
}
return response;
}
async refreshAccessToken() {
if (this.isRefreshing) {
return this.refreshPromise;
}
this.isRefreshing = true;
this.refreshPromise = fetch('/auth/refresh', {
method: 'POST',
credentials: 'include'
}).then(response => {
if (response.ok) {
return response.json().then(data => {
this.accessToken = data.accessToken;
return true;
});
}
return false;
}).finally(() => {
this.isRefreshing = false;
this.refreshPromise = null;
});
return this.refreshPromise;
}
}Security Checklist
Ensure your implementation includes these security measures:
- Environment Variables: Store all secrets in environment variables, never in code
- HTTPS Only: Always use HTTPS in production to prevent token interception
- Token Rotation: Implement automatic refresh token rotation
- Rate Limiting: Add rate limiting to authentication endpoints
- Logging: Log authentication events for security monitoring
- CORS Configuration: Properly configure CORS for your domain
Conclusion
Implementing secure JWT authentication requires careful consideration of multiple security vectors. By using short-lived access tokens, secure refresh token storage, and proper frontend integration, you can build a robust authentication system that protects against common attacks while providing a smooth user experience. Remember to regularly audit your implementation and stay updated with the latest security best practices.
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 Authentication with JWT and Refresh Tokens: A Complete Guide
Learn to implement robust JWT authentication with refresh tokens, preventing common security vulnerabilities in modern web applications.