JWT Security
Secure implementation of JSON Web Tokens for authentication.
When to Use
-
Implementing JWT authentication
-
Reviewing existing JWT code
-
Setting up refresh token rotation
-
Debugging JWT issues
-
Migrating to JWT-based auth
JWT Vulnerabilities
Vulnerability Risk Description
Algorithm None CRITICAL Accepting unsigned tokens
Algorithm Confusion CRITICAL RS256 → HS256 attack
Weak Secret HIGH Brute-forceable secrets
No Expiration HIGH Tokens valid forever
Sensitive Data in Payload MEDIUM JWT payload is base64, not encrypted
Token Leakage HIGH Exposed in logs/URLs
Secure Implementation
- Token Generation
const jwt = require('jsonwebtoken');
const JWT_CONFIG = { accessSecret: process.env.JWT_ACCESS_SECRET, // 256+ bit random string refreshSecret: process.env.JWT_REFRESH_SECRET, accessExpiry: '15m', // Short-lived refreshExpiry: '7d', // Longer-lived algorithm: 'HS256', // Or RS256 for asymmetric issuer: 'your-app-name', audience: 'your-app-users' };
function generateAccessToken(user) { const payload = { sub: user.id, // Subject (user ID) email: user.email, // Only non-sensitive data role: user.role, // Don't include: password, SSN, credit card, etc. };
return jwt.sign(payload, JWT_CONFIG.accessSecret, { algorithm: JWT_CONFIG.algorithm, expiresIn: JWT_CONFIG.accessExpiry, issuer: JWT_CONFIG.issuer, audience: JWT_CONFIG.audience, jwtid: crypto.randomUUID() // Unique token ID }); }
function generateRefreshToken(user) { const payload = { sub: user.id, type: 'refresh', family: crypto.randomUUID() // For refresh token rotation };
return jwt.sign(payload, JWT_CONFIG.refreshSecret, { algorithm: JWT_CONFIG.algorithm, expiresIn: JWT_CONFIG.refreshExpiry, issuer: JWT_CONFIG.issuer }); }
- Token Verification (CRITICAL)
function verifyAccessToken(token) { try { // CRITICAL: Always specify allowed algorithms return jwt.verify(token, JWT_CONFIG.accessSecret, { algorithms: [JWT_CONFIG.algorithm], // Whitelist! issuer: JWT_CONFIG.issuer, audience: JWT_CONFIG.audience, complete: true // Returns header + payload }); } catch (error) { if (error instanceof jwt.TokenExpiredError) { throw new AuthError('Token expired', 'TOKEN_EXPIRED'); } if (error instanceof jwt.JsonWebTokenError) { throw new AuthError('Invalid token', 'INVALID_TOKEN'); } throw error; } }
// VULNERABLE - Never do this! // jwt.verify(token, secret); // Accepts any algorithm! // jwt.decode(token); // No verification at all!
- Authentication Middleware
async function authenticateJWT(req, res, next) { // Extract token from header const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'No token provided' }); }
const token = authHeader.substring(7);
try { const decoded = verifyAccessToken(token);
// Optional: Check if token is blacklisted
if (await isTokenBlacklisted(decoded.payload.jti)) {
return res.status(401).json({ error: 'Token revoked' });
}
// Attach user to request
req.user = {
id: decoded.payload.sub,
email: decoded.payload.email,
role: decoded.payload.role
};
next();
} catch (error) { if (error.code === 'TOKEN_EXPIRED') { return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' }); } return res.status(401).json({ error: 'Invalid token' }); } }
- Refresh Token Rotation
// Store refresh tokens in database const refreshTokenSchema = { id: 'uuid', userId: 'uuid', tokenHash: 'string', // Store hash, not token family: 'string', // For rotation detection expiresAt: 'datetime', revokedAt: 'datetime?', replacedBy: 'uuid?' };
async function refreshTokens(refreshToken) { // Verify refresh token let decoded; try { decoded = jwt.verify(refreshToken, JWT_CONFIG.refreshSecret, { algorithms: [JWT_CONFIG.algorithm] }); } catch { throw new AuthError('Invalid refresh token'); }
// Find token in database const tokenHash = hashToken(refreshToken); const storedToken = await db.refreshTokens.findOne({ tokenHash, revokedAt: null });
if (!storedToken) { // Token not found or already revoked // Possible token reuse attack - revoke entire family await db.refreshTokens.updateMany( { family: decoded.family }, { revokedAt: new Date() } ); throw new AuthError('Refresh token reuse detected'); }
// Check expiration if (new Date() > storedToken.expiresAt) { throw new AuthError('Refresh token expired'); }
// Rotate: revoke old, create new const user = await db.users.findById(decoded.sub); const newAccessToken = generateAccessToken(user); const newRefreshToken = generateRefreshToken(user);
// Revoke old refresh token await db.refreshTokens.update(storedToken.id, { revokedAt: new Date(), replacedBy: newRefreshToken.id });
// Store new refresh token await db.refreshTokens.create({ userId: user.id, tokenHash: hashToken(newRefreshToken), family: decoded.family, // Same family for rotation tracking expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) });
return { accessToken: newAccessToken, refreshToken: newRefreshToken }; }
- Token Revocation
// Blacklist for immediate revocation const tokenBlacklist = new Map(); // In production, use Redis
async function revokeToken(token) {
const decoded = jwt.decode(token);
if (decoded && decoded.jti) {
// Store until token would naturally expire
const ttl = decoded.exp * 1000 - Date.now();
await redis.setex(blacklist:${decoded.jti}, ttl / 1000, '1');
}
}
async function isTokenBlacklisted(jti) {
return await redis.exists(blacklist:${jti});
}
// Logout endpoint app.post('/logout', authenticateJWT, async (req, res) => { // Revoke access token await revokeToken(req.headers.authorization.substring(7));
// Revoke all refresh tokens for user await db.refreshTokens.updateMany( { userId: req.user.id }, { revokedAt: new Date() } );
res.json({ success: true }); });
- Asymmetric Keys (RS256)
const fs = require('fs');
// For distributed systems or when verifier != issuer const privateKey = fs.readFileSync('private.pem'); const publicKey = fs.readFileSync('public.pem');
function generateTokenRS256(user) { return jwt.sign( { sub: user.id }, privateKey, { algorithm: 'RS256', expiresIn: '15m', issuer: 'auth-service' } ); }
function verifyTokenRS256(token) { return jwt.verify(token, publicKey, { algorithms: ['RS256'], // CRITICAL: Only allow RS256 issuer: 'auth-service' }); }
Generate RSA key pair
openssl genrsa -out private.pem 2048 openssl rsa -in private.pem -pubout -out public.pem
Token Storage (Client-Side)
// BEST: HttpOnly cookie (for web apps) res.cookie('accessToken', token, { httpOnly: true, // No JS access secure: true, // HTTPS only sameSite: 'strict', maxAge: 900000 // 15 minutes });
// OK: Memory (for SPAs, lost on refresh) let accessToken = null;
function setToken(token) { accessToken = token; }
// AVOID: localStorage (XSS vulnerable) // localStorage.setItem('token', token); // DON'T!
Common Mistakes
// MISTAKE 1: Not specifying algorithm jwt.verify(token, secret); // Vulnerable to algorithm switching!
// MISTAKE 2: Using decode instead of verify const user = jwt.decode(token); // No signature check!
// MISTAKE 3: Sensitive data in payload jwt.sign({ password: user.password }, secret); // NO!
// MISTAKE 4: Weak secret jwt.sign(payload, 'secret'); // Easily brute-forced!
// MISTAKE 5: No expiration jwt.sign(payload, secret); // No exp = valid forever!
// MISTAKE 6: Token in URL
res.redirect(/dashboard?token=${token}); // Logged everywhere!
Security Checklist
-
Algorithm explicitly whitelisted in verify()
-
Strong secret (256+ bits of entropy)
-
Short access token expiry (15 minutes)
-
Refresh token rotation implemented
-
No sensitive data in payload
-
Tokens not stored in localStorage
-
Tokens not passed in URLs
-
Token revocation mechanism exists
-
jti claim for unique identification
-
Issuer and audience validated
Best Practices
-
Whitelist Algorithms: Always specify allowed algorithms
-
Short Expiry: Access tokens should be short-lived
-
Rotate Refresh Tokens: New refresh token on each use
-
Use HttpOnly Cookies: For web applications
-
Strong Secrets: 256+ bit random secrets
-
No Sensitive Data: JWT payload is not encrypted
-
Implement Revocation: For logout and compromised tokens
-
Validate Everything: issuer, audience, expiry, signature