JWT Best Practices and Common Pitfalls
Introduction
JSON Web Tokens (JWTs) have become the de facto standard for stateless authentication in modern web applications. However, their widespread adoption has also revealed numerous security pitfalls that developers frequently encounter.
This comprehensive guide explores implementing JWT authentication securely, covering signing algorithms, token storage, expiration strategies, and common vulnerabilities to avoid in production systems.
Understanding JWT Structure
Token Anatomy
JWTs consist of three parts separated by dots:
// JWT Structure: header.payload.signature
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
// Decoded header
{
"alg": "HS256",
"typ": "JWT"
}
// Decoded payload
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": 1516242622
}
Secure Token Generation
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
class JWTService {
constructor(config) {
this.accessTokenSecret = config.accessTokenSecret;
this.refreshTokenSecret = config.refreshTokenSecret;
this.accessTokenExpiry = '15m';
this.refreshTokenExpiry = '7d';
}
generateTokenPair(userId, userData) {
// Generate unique token ID
const tokenId = crypto.randomBytes(16).toString('hex');
const accessToken = jwt.sign(
{
sub: userId,
jti: tokenId,
type: 'access',
...userData
},
this.accessTokenSecret,
{
algorithm: 'RS256', // Use asymmetric algorithm
expiresIn: this.accessTokenExpiry,
issuer: 'your-app-name',
audience: 'your-app-api'
}
);
const refreshToken = jwt.sign(
{
sub: userId,
jti: tokenId,
type: 'refresh'
},
this.refreshTokenSecret,
{
algorithm: 'RS256',
expiresIn: this.refreshTokenExpiry,
issuer: 'your-app-name'
}
);
return { accessToken, refreshToken, tokenId };
}
}
Pitfall #1: Using Weak Signing Algorithms
The Problem
Using none algorithm or weak symmetric keys compromises token integrity:
// DANGEROUS: Never use 'none' algorithm
const unsafeToken = jwt.sign(payload, '', { algorithm: 'none' });
// WEAK: Short symmetric keys are easily brute-forced
const weakSecret = 'secret123';
The Solution
Always use strong asymmetric algorithms with proper key management:
const fs = require('fs');
class SecureJWTService {
constructor() {
// Load RSA keys from secure storage
this.privateKey = fs.readFileSync('./keys/private.pem', 'utf8');
this.publicKey = fs.readFileSync('./keys/public.pem', 'utf8');
}
signToken(payload) {
return jwt.sign(payload, this.privateKey, {
algorithm: 'RS256', // RSA with SHA-256
keyid: 'key-2025-01'
});
}
verifyToken(token) {
try {
return jwt.verify(token, this.publicKey, {
algorithms: ['RS256'], // Whitelist allowed algorithms
issuer: 'your-app-name'
});
} catch (error) {
throw new Error(`Token verification failed: ${error.message}`);
}
}
}
Pitfall #2: Storing Sensitive Data in Tokens
The Problem
JWTs are encoded, not encrypted. Sensitive data in the payload is readable:
// DANGEROUS: Sensitive data exposed
const badToken = jwt.sign({
userId: 123,
email: 'user@example.com',
password: 'hashed_password', // Never include this
creditCard: '1234-5678-9012-3456', // Never include this
ssn: '123-45-6789' // Never include this
}, secret);
The Solution
Only include necessary, non-sensitive claims:
function createSecurePayload(user) {
return {
sub: user.id, // Subject (user ID)
jti: generateTokenId(), // Unique token ID
iat: Math.floor(Date.now() / 1000), // Issued at
exp: Math.floor(Date.now() / 1000) + (15 * 60), // Expires in 15 min
roles: user.roles, // User roles
permissions: user.permissions.map(p => p.name), // Permission names only
// Never include passwords, sensitive PII, or financial data
};
}
Pitfall #3: Improper Token Storage
The Problem
Storing tokens in localStorage makes them vulnerable to XSS attacks:
// VULNERABLE to XSS
localStorage.setItem('accessToken', token);
// Also vulnerable
sessionStorage.setItem('accessToken', token);
The Solution
Use httpOnly cookies for enhanced security:
// Express.js example
app.post('/login', async (req, res) => {
const { accessToken, refreshToken } = await authenticateUser(req.body);
// Store refresh token in httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // Only sent over HTTPS
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth/refresh'
});
// Send access token in response body (store in memory)
res.json({
accessToken,
expiresIn: 900 // 15 minutes
});
});
Pitfall #4: Missing Token Revocation
The Problem
JWTs are stateless, making them difficult to revoke before expiration:
// User logs out but token remains valid until expiration
app.post('/logout', (req, res) => {
// Token still works for 15 minutes!
res.json({ message: 'Logged out' });
});
The Solution
Implement token blacklisting with Redis:
const redis = require('redis');
const client = redis.createClient();
class TokenBlacklist {
async revokeToken(token) {
const decoded = jwt.decode(token);
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await client.setEx(
`blacklist:${decoded.jti}`,
ttl,
'revoked'
);
}
}
async isRevoked(token) {
const decoded = jwt.decode(token);
const result = await client.get(`blacklist:${decoded.jti}`);
return result === 'revoked';
}
}
// Middleware to check blacklist
async function checkBlacklist(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
const blacklist = new TokenBlacklist();
if (await blacklist.isRevoked(token)) {
return res.status(401).json({ error: 'Token has been revoked' });
}
next();
}
Pitfall #5: Inadequate Expiration Times
The Problem
Long-lived access tokens increase security risk:
// DANGEROUS: Access token valid for 30 days
const token = jwt.sign(payload, secret, { expiresIn: '30d' });
The Solution
Use short-lived access tokens with refresh token rotation:
class TokenManager {
issueTokens(userId) {
const accessToken = jwt.sign(
{ sub: userId, type: 'access' },
accessSecret,
{ expiresIn: '15m' } // Short-lived
);
const refreshToken = jwt.sign(
{ sub: userId, type: 'refresh' },
refreshSecret,
{ expiresIn: '7d' } // Longer-lived
);
return { accessToken, refreshToken };
}
async refreshAccessToken(refreshToken) {
try {
const decoded = jwt.verify(refreshToken, refreshSecret);
if (decoded.type !== 'refresh') {
throw new Error('Invalid token type');
}
// Generate new token pair
const newTokens = this.issueTokens(decoded.sub);
// Rotate refresh token
await this.revokeToken(refreshToken);
return newTokens;
} catch (error) {
throw new Error('Refresh token invalid or expired');
}
}
}
Pitfall #6: Missing Audience and Issuer Validation
The Problem
Without audience and issuer validation, tokens can be misused across services:
// INCOMPLETE: No audience/issuer validation
jwt.verify(token, publicKey);
The Solution
Always validate audience and issuer claims:
class JWTValidator {
constructor(config) {
this.publicKey = config.publicKey;
this.expectedIssuer = config.issuer;
this.expectedAudience = config.audience;
}
validateToken(token) {
try {
return jwt.verify(token, this.publicKey, {
algorithms: ['RS256'],
issuer: this.expectedIssuer,
audience: this.expectedAudience,
clockTolerance: 30 // 30 seconds clock skew tolerance
});
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new Error('Token has expired');
} else if (error.name === 'JsonWebTokenError') {
throw new Error('Token is invalid');
} else if (error.name === 'NotBeforeError') {
throw new Error('Token not yet valid');
}
throw error;
}
}
}
// Usage
const validator = new JWTValidator({
publicKey: process.env.JWT_PUBLIC_KEY,
issuer: 'https://auth.yourapp.com',
audience: 'https://api.yourapp.com'
});
Complete Implementation Example
Express.js Authentication Flow
const express = require('express');
const app = express();
// Secure JWT service with all best practices
class SecureAuthService {
constructor() {
this.jwtService = new SecureJWTService();
this.tokenManager = new TokenManager();
this.blacklist = new TokenBlacklist();
}
async authenticate(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
// Check blacklist
if (await this.blacklist.isRevoked(token)) {
return res.status(401).json({ error: 'Token revoked' });
}
// Verify token
const decoded = this.jwtService.verifyToken(token);
// Attach user info to request
req.user = {
id: decoded.sub,
roles: decoded.roles,
permissions: decoded.permissions
};
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
}
}
// Instantiate service
const authService = new SecureAuthService();
// Protected route
app.get('/api/protected',
authService.authenticate.bind(authService),
(req, res) => {
res.json({ data: 'Protected data', user: req.user });
}
);
// Logout endpoint with token revocation
app.post('/api/logout',
authService.authenticate.bind(authService),
async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
await authService.blacklist.revokeToken(token);
res.json({ message: 'Logged out successfully' });
}
);
Security Best Practices Checklist
Essential practices for JWT security:
- Algorithm Security: Use RS256 or ES256, never HS256 with weak secrets or ‘none’
- Token Lifetime: Keep access tokens short-lived (5-15 minutes)
- Secure Storage: Use httpOnly cookies for refresh tokens, memory for access tokens
- Revocation Strategy: Implement token blacklisting with Redis or similar
- Claim Validation: Always validate iss, aud, exp, and nbf claims
- Sensitive Data: Never store passwords, PII, or financial data in tokens
- HTTPS Only: Always transmit tokens over HTTPS
- CSRF Protection: Use SameSite cookies and implement CSRF tokens
- Refresh Token Rotation: Generate new refresh token on each use
- Key Management: Rotate signing keys regularly, store securely
Testing JWT Implementation
const request = require('supertest');
describe('JWT Authentication', () => {
let app;
let authService;
beforeEach(() => {
app = createApp();
authService = new SecureAuthService();
});
it('should reject requests without token', async () => {
const response = await request(app)
.get('/api/protected')
.expect(401);
expect(response.body.error).toBe('No token provided');
});
it('should reject expired tokens', async () => {
const expiredToken = jwt.sign(
{ sub: '123' },
privateKey,
{ expiresIn: '-1h' }
);
const response = await request(app)
.get('/api/protected')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
expect(response.body.error).toBe('Invalid token');
});
it('should reject revoked tokens', async () => {
const { accessToken } = authService.tokenManager.issueTokens('123');
await authService.blacklist.revokeToken(accessToken);
const response = await request(app)
.get('/api/protected')
.set('Authorization', `Bearer ${accessToken}`)
.expect(401);
expect(response.body.error).toBe('Token revoked');
});
it('should accept valid tokens', async () => {
const { accessToken } = authService.tokenManager.issueTokens('123');
const response = await request(app)
.get('/api/protected')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(response.body.user.id).toBe('123');
});
});
Conclusion
Implementing JWT authentication securely requires attention to numerous details and potential pitfalls. By following the best practices outlined in this guide, you can avoid common vulnerabilities and build a robust authentication system.
Key takeaways:
- Always use strong asymmetric algorithms like RS256
- Keep access tokens short-lived and implement refresh token rotation
- Never store sensitive data in JWT payloads
- Implement token revocation with blacklisting
- Validate all token claims including issuer and audience
- Store tokens securely using httpOnly cookies
- Always transmit tokens over HTTPS
- Regularly rotate signing keys
Remember that JWT security is not a one-time implementation but requires ongoing vigilance and updates as new vulnerabilities are discovered. Stay informed about the latest security advisories and regularly audit your authentication implementation.
By treating JWT security as a critical component of your application architecture and following these best practices, you can provide your users with secure, scalable authentication while minimizing the risk of token-based attacks.