Building Secure REST APIs with JWT Authentication: A Complete Implementation Guide
Introduction
JSON Web Tokens (JWT) have become the de facto standard for API authentication in modern web applications. However, implementing JWT authentication securely requires understanding both the technology and its potential vulnerabilities. In this comprehensive guide, we'll build a secure JWT authentication system from scratch, covering best practices and common pitfalls.
Understanding JWT Structure and Security Implications
A JWT consists of three parts: header, payload, and signature, separated by dots. While the data is base64-encoded, it's not encrypted by default, making proper implementation crucial for security.
// JWT Structure
// header.payload.signature
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cSetting Up Secure JWT Authentication with Node.js
Let's implement a robust authentication system using Node.js, Express, and proper security practices:
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
// Environment variables for security
const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(64).toString('hex');
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || crypto.randomBytes(64).toString('hex');
const JWT_EXPIRE = '15m';
const REFRESH_EXPIRE = '7d';
class AuthService {
// Generate access and refresh tokens
static generateTokens(userId, userRole) {
const payload = {
sub: userId,
role: userRole,
iat: Math.floor(Date.now() / 1000),
jti: crypto.randomUUID() // Unique token ID for revocation
};
const accessToken = jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRE,
issuer: 'your-app-name',
audience: 'your-app-users'
});
const refreshToken = jwt.sign(
{ sub: userId, jti: payload.jti },
JWT_REFRESH_SECRET,
{ expiresIn: REFRESH_EXPIRE }
);
return { accessToken, refreshToken };
}
// Verify and decode JWT
static verifyToken(token, secret = JWT_SECRET) {
try {
return jwt.verify(token, secret, {
issuer: 'your-app-name',
audience: 'your-app-users'
});
} catch (error) {
throw new Error('Invalid token');
}
}
}Implementing Secure Authentication Middleware
Create middleware that properly handles token validation and implements security best practices:
// Authentication middleware
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({
error: 'Access token required',
code: 'TOKEN_MISSING'
});
}
try {
const decoded = AuthService.verifyToken(token);
// Check token blacklist (implement Redis for production)
if (isTokenBlacklisted(decoded.jti)) {
return res.status(401).json({
error: 'Token has been revoked',
code: 'TOKEN_REVOKED'
});
}
req.user = {
id: decoded.sub,
role: decoded.role,
tokenId: decoded.jti
};
next();
} catch (error) {
return res.status(403).json({
error: 'Invalid or expired token',
code: 'TOKEN_INVALID'
});
}
};Secure Login Implementation
Implement a login endpoint with proper password validation and rate limiting:
const rateLimit = require('express-rate-limit');
// Rate limiting for login attempts
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: { error: 'Too many login attempts' },
standardHeaders: true,
legacyHeaders: false
});
// Login endpoint
app.post('/api/auth/login', loginLimiter, async (req, res) => {
try {
const { email, password } = req.body;
// Input validation
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
// Find user (implement your user model)
const user = await User.findByEmail(email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate tokens
const { accessToken, refreshToken } = AuthService.generateTokens(
user.id,
user.role
);
// Store refresh token securely (database)
await storeRefreshToken(user.id, refreshToken);
// 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 and Logout Implementation
Implement secure token refresh and logout functionality:
// Token refresh endpoint
app.post('/api/auth/refresh', async (req, res) => {
try {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
// Verify refresh token
const decoded = AuthService.verifyToken(refreshToken, JWT_REFRESH_SECRET);
// Validate refresh token in database
const isValidRefreshToken = await validateRefreshToken(decoded.sub, refreshToken);
if (!isValidRefreshToken) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
// Generate new tokens
const user = await User.findById(decoded.sub);
const tokens = AuthService.generateTokens(user.id, user.role);
// Update stored refresh token
await updateRefreshToken(user.id, tokens.refreshToken);
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken: tokens.accessToken });
} catch (error) {
res.status(403).json({ error: 'Token refresh failed' });
}
});
// Logout endpoint
app.post('/api/auth/logout', authenticateToken, async (req, res) => {
try {
// Blacklist current access token
await blacklistToken(req.user.tokenId);
// Remove refresh token from database
await removeRefreshToken(req.user.id);
// Clear refresh token cookie
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
} catch (error) {
res.status(500).json({ error: 'Logout failed' });
}
});Security Best Practices and Considerations
When implementing JWT authentication, always follow these security practices:
- Use strong secrets: Generate cryptographically secure secrets and store them as environment variables
- Short token expiration: Keep access tokens short-lived (15-30 minutes) and use refresh tokens
- Secure storage: Store refresh tokens in HTTP-only cookies, never in localStorage
- Token blacklisting: Implement a blacklist mechanism for revoked tokens using Redis
- Rate limiting: Protect login endpoints from brute force attacks
- HTTPS only: Never transmit tokens over unencrypted connections
Conclusion
Implementing secure JWT authentication requires careful attention to security best practices. By following the patterns shown in this guide—using proper token expiration, secure storage, blacklisting mechanisms, and rate limiting—you can build robust authentication systems that protect your users and applications. Remember to regularly review and update your security implementations as threats evolve.
Related Posts
Implementing Multi-Factor Authentication with JWT and Time-Based OTP in Node.js
Learn to build secure MFA using JWT tokens and time-based one-time passwords with practical Node.js implementation.
Implementing OAuth 2.0 Authentication with PKCE in Modern Web Applications
Learn how to implement secure OAuth 2.0 authentication with PKCE flow to protect your web applications from common security vulnerabilities.
Building a High-Performance Authentication System with JWT and Redis
Learn to implement secure JWT authentication with Redis for session management, refresh tokens, and blacklisting in Node.js applications.