Building Secure User Authentication with JWT and Refresh Tokens in Node.js
Introduction
Authentication is the cornerstone of web application security, yet many developers still implement it incorrectly. JSON Web Tokens (JWT) have become the de facto standard for stateless authentication, but they come with security pitfalls that can expose your application to serious vulnerabilities.
In this comprehensive guide, we'll build a secure authentication system using JWT access tokens paired with refresh tokens, implementing proper rotation strategies and security measures that follow industry best practices.
Understanding JWT Security Challenges
Before diving into implementation, let's understand why basic JWT authentication isn't enough:
- Long-lived tokens: If compromised, they remain valid until expiration
- No revocation mechanism: Pure JWTs can't be invalidated server-side
- XSS vulnerabilities: Storing tokens in localStorage exposes them to script attacks
- CSRF attacks: Automatic cookie inclusion can be exploited
The solution? Implement a dual-token system with proper security measures.
Setting Up the Project Structure
Let's start by setting up our Node.js project with the necessary dependencies:
npm init -y
npm install express jsonwebtoken bcryptjs cookie-parser helmet cors dotenv
npm install -D nodemonCreate the basic project structure:
├── src/
│ ├── controllers/
│ ├── middleware/
│ ├── models/
│ ├── routes/
│ └── utils/
├── .env
└── server.jsImplementing the Token Service
First, let's create a robust token service that handles both access and refresh tokens:
// src/utils/tokenService.js
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
class TokenService {
generateAccessToken(payload) {
return jwt.sign(payload, process.env.ACCESS_TOKEN_SECRET, {
expiresIn: '15m',
issuer: 'your-app-name',
audience: 'your-app-users'
});
}
generateRefreshToken() {
return crypto.randomBytes(64).toString('hex');
}
verifyAccessToken(token) {
try {
return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, {
issuer: 'your-app-name',
audience: 'your-app-users'
});
} catch (error) {
throw new Error('Invalid access token');
}
}
generateTokenPair(userId) {
const payload = { userId, type: 'access' };
const accessToken = this.generateAccessToken(payload);
const refreshToken = this.generateRefreshToken();
return { accessToken, refreshToken };
}
}
module.exports = new TokenService();Secure Authentication Controller
Now, let's implement the authentication controller with proper security measures:
// src/controllers/authController.js
const bcrypt = require('bcryptjs');
const tokenService = require('../utils/tokenService');
const User = require('../models/User'); // Your user model
class AuthController {
async login(req, res) {
try {
const { email, password } = req.body;
// Rate limiting should be implemented at middleware level
const user = await User.findByEmail(email);
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const { accessToken, refreshToken } = tokenService.generateTokenPair(user.id);
// Store refresh token in database with expiration
await User.storeRefreshToken(user.id, refreshToken, new Date(Date.now() + 7 * 24 * 60 * 60 * 1000));
// Set secure HTTP-only cookies
this.setTokenCookies(res, accessToken, refreshToken);
res.json({
user: { id: user.id, email: user.email, name: user.name },
message: 'Login successful'
});
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
}
async refreshToken(req, res) {
try {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
const storedToken = await User.findRefreshToken(refreshToken);
if (!storedToken || storedToken.expires_at < new Date()) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Generate new token pair
const { accessToken, refreshToken: newRefreshToken } =
tokenService.generateTokenPair(storedToken.user_id);
// Rotate refresh token
await User.rotateRefreshToken(refreshToken, newRefreshToken);
this.setTokenCookies(res, accessToken, newRefreshToken);
res.json({ message: 'Token refreshed successfully' });
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
}
setTokenCookies(res, accessToken, refreshToken) {
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
domain: process.env.COOKIE_DOMAIN
};
res.cookie('accessToken', accessToken, {
...cookieOptions,
maxAge: 15 * 60 * 1000 // 15 minutes
});
res.cookie('refreshToken', refreshToken, {
...cookieOptions,
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
}
async logout(req, res) {
try {
const { refreshToken } = req.cookies;
if (refreshToken) {
await User.revokeRefreshToken(refreshToken);
}
res.clearCookie('accessToken');
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
}
}
module.exports = new AuthController();Authentication Middleware
Create middleware to protect routes and handle token validation:
// src/middleware/authMiddleware.js
const tokenService = require('../utils/tokenService');
const authenticateToken = async (req, res, next) => {
try {
const { accessToken } = req.cookies;
if (!accessToken) {
return res.status(401).json({ error: 'Access token required' });
}
const decoded = tokenService.verifyAccessToken(accessToken);
req.user = { userId: decoded.userId };
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
res.status(403).json({ error: 'Invalid token' });
}
};
module.exports = { authenticateToken };Security Best Practices Implementation
Let's enhance our setup with additional security measures:
// server.js
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const cookieParser = require('cookie-parser');
require('dotenv').config();
const app = express();
// Security middleware
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"]
}
}
}));
app.use(cors({
origin: process.env.CLIENT_URL,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(cookieParser());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Routes
app.use('/api/auth', require('./src/routes/auth'));
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});Frontend Integration Tips
When integrating with your frontend:
- Automatic token refresh: Implement interceptors to handle token expiration
- Secure storage: Never store tokens in localStorage; rely on HTTP-only cookies
- Error handling: Properly handle authentication errors and redirect users
- Logout cleanup: Always call the logout endpoint to revoke tokens
Conclusion
Implementing secure JWT authentication requires more than just signing tokens. By combining short-lived access tokens with rotating refresh tokens, using HTTP-only cookies, and following security best practices, you create a robust authentication system that protects against common attack vectors.
Remember to regularly audit your authentication implementation, keep dependencies updated, and consider implementing additional security measures like rate limiting and account lockouts for production applications.
Related Posts
Building Secure Authentication with JWT and OAuth 2.0: A Complete Implementation Guide
Learn to implement bulletproof authentication using JWT tokens and OAuth 2.0 with practical security measures and code examples.
Building Secure REST APIs: A Complete Authentication and Authorization Guide
Master API security with JWT tokens, role-based access control, and essential security practices for production applications.
Building a Secure JWT Authentication System with Express.js and bcrypt
Learn to implement bulletproof JWT authentication in Node.js with proper password hashing, token management, and security best practices.