JWT Authentication in Node.js: Complete Security Guide
By Braincuber Team
Published on January 29, 2026
Building secure APIs is non-negotiable in modern web development. JSON Web Tokens (JWT) have become the de facto standard for handling authentication in Node.js applications because they're stateless, scalable, and work seamlessly across distributed systems. But here's the thing—I've seen countless projects implement JWT authentication incorrectly, leaving gaping security holes that attackers love to exploit.
In this guide, I'll walk you through a production-ready JWT authentication system using Node.js and Express. We'll cover everything from basic token generation to advanced security patterns like refresh tokens and token revocation. By the end, you'll have a solid understanding of how to protect your APIs without sacrificing developer experience.
- Complete authentication system with login, register, and protected routes
- Access tokens with short expiration for security
- Refresh tokens with rotation for seamless user experience
- Middleware for route protection and role-based access control
- Token revocation and logout functionality
Understanding JWT Structure
Before we write any code, let's understand what we're working with. A JWT consists of three parts separated by dots: header, payload, and signature. Each part is Base64-encoded, making the token compact enough to pass in HTTP headers.
Header
Contains the token type (JWT) and signing algorithm (HS256, RS256, etc.). Never trust the algorithm from the token—always validate against your expected algorithm.
Payload
Contains claims about the user—like user ID, email, and roles. Keep it minimal; every byte adds to request overhead and can be decoded by anyone.
Signature
Cryptographic hash of header + payload using your secret key. This is what prevents tampering—any modification invalidates the signature.
Project Setup
Let's start by setting up our Node.js project with the necessary dependencies. We'll use Express for the server, jsonwebtoken for JWT handling, and bcrypt for password hashing.
Initialize Project & Install Dependencies
# Create project directory
mkdir jwt-auth-api && cd jwt-auth-api
# Initialize Node.js project
npm init -y
# Install production dependencies
npm install express jsonwebtoken bcryptjs dotenv cookie-parser
# Install development dependencies
npm install -D nodemon
Environment Configuration
Never hardcode secrets in your source code. Use environment variables for all sensitive configuration.
# Server Configuration
PORT=3000
NODE_ENV=development
# JWT Secrets (use strong, random strings in production)
ACCESS_TOKEN_SECRET=your-access-token-secret-min-32-chars
REFRESH_TOKEN_SECRET=your-refresh-token-secret-min-32-chars
# Token Expiration
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
# Database (example with MongoDB)
MONGODB_URI=mongodb://localhost:27017/jwt-auth-demo
Generate secrets using crypto.randomBytes(64).toString('hex') in Node.js. Never use simple strings like "secret" or "password" in production—attackers can brute-force weak secrets.
Core Authentication Implementation
Express Server Setup
Create the main server file with essential middleware for security and parsing.
require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const authRoutes = require('./routes/auth');
const protectedRoutes = require('./routes/protected');
const app = express();
// Middleware
app.use(express.json());
app.use(cookieParser());
// Security headers
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
next();
});
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/protected', protectedRoutes);
// Error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
success: false,
message: 'Internal server error'
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Token Generation Utilities
Create utility functions for generating and verifying tokens. We'll use separate secrets for access and refresh tokens for added security.
const jwt = require('jsonwebtoken');
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
const ACCESS_TOKEN_EXPIRY = process.env.ACCESS_TOKEN_EXPIRY || '15m';
const REFRESH_TOKEN_EXPIRY = process.env.REFRESH_TOKEN_EXPIRY || '7d';
/**
* Generate access token with user data
* @param {Object} user - User object with id, email, role
* @returns {string} JWT access token
*/
function generateAccessToken(user) {
const payload = {
userId: user.id,
email: user.email,
role: user.role || 'user'
};
return jwt.sign(payload, ACCESS_TOKEN_SECRET, {
expiresIn: ACCESS_TOKEN_EXPIRY,
issuer: 'your-app-name',
audience: 'your-app-users'
});
}
/**
* Generate refresh token with minimal data
* @param {Object} user - User object with id
* @returns {string} JWT refresh token
*/
function generateRefreshToken(user) {
const payload = {
userId: user.id,
tokenVersion: user.tokenVersion || 0
};
return jwt.sign(payload, REFRESH_TOKEN_SECRET, {
expiresIn: REFRESH_TOKEN_EXPIRY,
issuer: 'your-app-name'
});
}
/**
* Verify access token
* @param {string} token - JWT access token
* @returns {Object} Decoded payload or throws error
*/
function verifyAccessToken(token) {
return jwt.verify(token, ACCESS_TOKEN_SECRET, {
issuer: 'your-app-name',
audience: 'your-app-users'
});
}
/**
* Verify refresh token
* @param {string} token - JWT refresh token
* @returns {Object} Decoded payload or throws error
*/
function verifyRefreshToken(token) {
return jwt.verify(token, REFRESH_TOKEN_SECRET, {
issuer: 'your-app-name'
});
}
module.exports = {
generateAccessToken,
generateRefreshToken,
verifyAccessToken,
verifyRefreshToken
};
Authentication Routes
Implement the core authentication endpoints: register, login, refresh token, and logout.
const express = require('express');
const bcrypt = require('bcryptjs');
const router = express.Router();
const {
generateAccessToken,
generateRefreshToken,
verifyRefreshToken
} = require('../utils/tokenUtils');
// In-memory user store (use database in production)
const users = [];
const refreshTokens = new Set();
/**
* POST /api/auth/register
* Register a new user
*/
router.post('/register', async (req, res) => {
try {
const { email, password, name } = req.body;
// Validation
if (!email || !password || !name) {
return res.status(400).json({
success: false,
message: 'Email, password, and name are required'
});
}
// Check if user exists
const existingUser = users.find(u => u.email === email);
if (existingUser) {
return res.status(409).json({
success: false,
message: 'User with this email already exists'
});
}
// Hash password (cost factor 12 for security)
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = {
id: Date.now().toString(),
email,
name,
password: hashedPassword,
role: 'user',
tokenVersion: 0,
createdAt: new Date()
};
users.push(user);
// Generate tokens
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Store refresh token
refreshTokens.add(refreshToken);
// Set refresh token in 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
});
res.status(201).json({
success: true,
message: 'User registered successfully',
data: {
user: { id: user.id, email: user.email, name: user.name },
accessToken
}
});
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({
success: false,
message: 'Registration failed'
});
}
});
/**
* POST /api/auth/login
* Authenticate user and return tokens
*/
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
// Find user
const user = users.find(u => u.email === email);
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid email or password'
});
}
// Verify password
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
return res.status(401).json({
success: false,
message: 'Invalid email or password'
});
}
// Generate tokens
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Store refresh token
refreshTokens.add(refreshToken);
// Set refresh token in HTTP-only cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({
success: true,
message: 'Login successful',
data: {
user: { id: user.id, email: user.email, name: user.name },
accessToken
}
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
success: false,
message: 'Login failed'
});
}
});
/**
* POST /api/auth/refresh
* Get new access token using refresh token
*/
router.post('/refresh', (req, res) => {
try {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({
success: false,
message: 'Refresh token not found'
});
}
// Check if refresh token is in our store
if (!refreshTokens.has(refreshToken)) {
return res.status(403).json({
success: false,
message: 'Invalid refresh token'
});
}
// Verify refresh token
const decoded = verifyRefreshToken(refreshToken);
// Find user
const user = users.find(u => u.id === decoded.userId);
if (!user) {
return res.status(403).json({
success: false,
message: 'User not found'
});
}
// Check token version (for token revocation)
if (decoded.tokenVersion !== user.tokenVersion) {
return res.status(403).json({
success: false,
message: 'Token has been revoked'
});
}
// Generate new access token
const accessToken = generateAccessToken(user);
// Optionally rotate refresh token
refreshTokens.delete(refreshToken);
const newRefreshToken = generateRefreshToken(user);
refreshTokens.add(newRefreshToken);
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({
success: true,
data: { accessToken }
});
} catch (error) {
console.error('Token refresh error:', error);
res.status(403).json({
success: false,
message: 'Invalid refresh token'
});
}
});
/**
* POST /api/auth/logout
* Invalidate refresh token
*/
router.post('/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
refreshTokens.delete(refreshToken);
}
res.clearCookie('refreshToken');
res.json({
success: true,
message: 'Logged out successfully'
});
});
module.exports = router;
Authentication Middleware
Protected Route Middleware
Create middleware to verify access tokens and protect routes. This middleware extracts the token from the Authorization header and validates it.
const { verifyAccessToken } = require('../utils/tokenUtils');
/**
* Middleware to authenticate requests using JWT
* Expects: Authorization: Bearer
*/
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({
success: false,
message: 'Access token required'
});
}
try {
const decoded = verifyAccessToken(token);
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
message: 'Access token expired',
code: 'TOKEN_EXPIRED'
});
}
return res.status(403).json({
success: false,
message: 'Invalid access token'
});
}
}
/**
* Middleware for role-based access control
* @param {...string} allowedRoles - Roles that can access the route
*/
function authorizeRoles(...allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
message: 'Authentication required'
});
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
success: false,
message: 'Insufficient permissions'
});
}
next();
};
}
module.exports = {
authenticateToken,
authorizeRoles
};
Protected Routes
Create routes that require authentication and demonstrate role-based access control.
const express = require('express');
const router = express.Router();
const { authenticateToken, authorizeRoles } = require('../middleware/auth');
/**
* GET /api/protected/profile
* Get current user's profile (requires authentication)
*/
router.get('/profile', authenticateToken, (req, res) => {
res.json({
success: true,
data: {
userId: req.user.userId,
email: req.user.email,
role: req.user.role
}
});
});
/**
* GET /api/protected/dashboard
* Protected dashboard data
*/
router.get('/dashboard', authenticateToken, (req, res) => {
res.json({
success: true,
data: {
message: `Welcome to your dashboard, ${req.user.email}!`,
stats: {
totalOrders: 42,
pendingTasks: 7,
notifications: 3
}
}
});
});
/**
* GET /api/protected/admin
* Admin-only route (requires admin role)
*/
router.get('/admin',
authenticateToken,
authorizeRoles('admin'),
(req, res) => {
res.json({
success: true,
data: {
message: 'Welcome to the admin panel',
adminFeatures: ['User Management', 'System Settings', 'Analytics']
}
});
}
);
/**
* GET /api/protected/moderator
* Moderator or admin route
*/
router.get('/moderator',
authenticateToken,
authorizeRoles('admin', 'moderator'),
(req, res) => {
res.json({
success: true,
data: {
message: 'Moderator panel access granted',
features: ['Content Moderation', 'User Reports']
}
});
}
);
module.exports = router;
Security Best Practices
Implementing JWT authentication correctly is just the beginning. Here are critical security measures you should always follow:
| Practice | Why It Matters | Implementation |
|---|---|---|
| Short Access Token Expiry | Limits damage if token is stolen | 15 minutes is a good balance |
| HTTP-Only Cookies for Refresh Tokens | Prevents XSS attacks from stealing tokens | Set httpOnly: true in cookie options |
| Strong Secrets | Weak secrets can be brute-forced | Use 256-bit random strings minimum |
| Token Rotation | Limits refresh token reuse attacks | Issue new refresh token on each use |
| Secure Flag in Production | Ensures cookies only sent over HTTPS | Set secure: true when NODE_ENV=production |
| SameSite Cookie Attribute | Prevents CSRF attacks | Set sameSite: 'strict' or 'lax' |
Frontend Integration
Here's how to integrate this authentication system with a frontend application:
API Client with Token Refresh
class AuthApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.accessToken = null;
}
setAccessToken(token) {
this.accessToken = token;
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const config = {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
credentials: 'include' // Important for cookies
};
if (this.accessToken) {
config.headers['Authorization'] = `Bearer ${this.accessToken}`;
}
let response = await fetch(url, config);
// If token expired, try to refresh
if (response.status === 401) {
const data = await response.json();
if (data.code === 'TOKEN_EXPIRED') {
const refreshed = await this.refreshToken();
if (refreshed) {
// Retry original request with new token
config.headers['Authorization'] = `Bearer ${this.accessToken}`;
response = await fetch(url, config);
} else {
// Refresh failed, redirect to login
window.location.href = '/login';
return null;
}
}
}
return response.json();
}
async refreshToken() {
try {
const response = await fetch(`${this.baseUrl}/api/auth/refresh`, {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
this.accessToken = data.data.accessToken;
return true;
}
return false;
} catch (error) {
console.error('Token refresh failed:', error);
return false;
}
}
async login(email, password) {
const response = await this.request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
if (response.success) {
this.accessToken = response.data.accessToken;
}
return response;
}
async logout() {
await this.request('/api/auth/logout', { method: 'POST' });
this.accessToken = null;
}
}
// Usage
const api = new AuthApiClient('http://localhost:3000');
// Login
const result = await api.login('user@example.com', 'password123');
// Make authenticated requests
const profile = await api.request('/api/protected/profile');
Testing Your Authentication
Use these curl commands to test your authentication endpoints:
# Register a new user
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"SecurePass123!","name":"Test User"}'
# Login
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"SecurePass123!"}' \
-c cookies.txt
# Access protected route (replace TOKEN with actual access token)
curl http://localhost:3000/api/protected/profile \
-H "Authorization: Bearer TOKEN"
# Refresh token
curl -X POST http://localhost:3000/api/auth/refresh \
-b cookies.txt -c cookies.txt
# Logout
curl -X POST http://localhost:3000/api/auth/logout \
-b cookies.txt
- Use HTTPS in production (required for secure cookies)
- Store refresh tokens in a database with user association
- Implement rate limiting on auth endpoints
- Add request logging for security auditing
- Set up token blacklisting for immediate revocation
- Use environment-specific secrets (never share between dev/prod)
Frequently Asked Questions
Access tokens are short-lived (typically 15 minutes) and used for authenticating API requests. They contain user data and are sent with every request. Refresh tokens are long-lived (7+ days), stored securely in HTTP-only cookies, and only used to obtain new access tokens when the current one expires. This separation limits the damage if an access token is stolen.
Store refresh tokens in HTTP-only cookies to prevent XSS attacks—JavaScript cannot access HTTP-only cookies. Access tokens can be kept in memory (not localStorage) during the session. LocalStorage is vulnerable to XSS attacks where malicious scripts can steal tokens. While cookies are vulnerable to CSRF, using SameSite='strict' and CSRF tokens mitigates this risk.
Since JWTs are stateless, you can't truly revoke them. However, you can implement token versioning—store a tokenVersion in the user record and include it in refresh tokens. When you need to revoke tokens (like on password change), increment the version. During token refresh, compare versions and reject mismatches. For immediate revocation, maintain a blacklist of revoked tokens checked during verification.
For most applications, HS256 (HMAC-SHA256) with a strong secret is sufficient and performant. Use RS256 (RSA-SHA256) when you need to verify tokens without the signing key—useful in microservices where only one service signs tokens but many verify them. Never use 'none' algorithm, and always validate the algorithm in your verification code to prevent algorithm confusion attacks.
Use asymmetric algorithms (RS256) so only your auth service has the private key for signing, while other services have the public key for verification. Alternatively, use a shared secret with HS256 if services are in a trusted network. Consider using an API gateway to centralize authentication, and include service-specific claims in tokens for fine-grained access control across services.
