Guide · Developer security
Common JWT Implementation Mistakes
Learn from the most dangerous JWT implementation mistakes that developers make. Avoid critical security vulnerabilities and protect your applications with proper JWT practices.
Critical Security Alert
Authentication bypassForged tokens can impersonate trusted usersPrivilege escalationUnvalidated claims can grant excess accessPersistent accessLong-lived tokens extend the attack windowCritical Security Mistakes
Using Weak or Predictable Secretscritical
Many developers use simple, short, or predictable secrets for signing JWTs. This makes tokens trivially easy to forge.
// VULNERABLE: NEVER do this
const secret = 'secret';
const secret = 'myapp';
const secret = '123456';
const secret = 'your-secret-key'; // Default examples
const token = jwt.sign(payload, secret);// SECURE: Always do this
const crypto = require('crypto');
const secret = process.env.JWT_SECRET ||
crypto.randomBytes(64).toString('hex');
// Minimum 256-bit (32 bytes) entropy
if (secret.length < 32) {
throw new Error('JWT secret too weak');
}
const token = jwt.sign(payload, secret);- Generate secrets with at least 256 bits of entropy
- Use cryptographically secure random generators
- Store secrets in environment variables, never in code
- Use different secrets for different environments
- Rotate secrets regularly
Algorithm Confusion Attackscritical
Not specifying or validating the signing algorithm allows attackers to change the algorithm and bypass security.
// VULNERABLE: Accepts any algorithm
jwt.verify(token, publicKey);
// VULNERABLE to 'none' algorithm
jwt.verify(token, secret, { algorithms: ['HS256', 'none'] });
// VULNERABLE: Using public key for HMAC
jwt.verify(token, publicKey, { algorithms: ['HS256', 'RS256'] });// SECURE: Always specify expected algorithm
jwt.verify(token, secret, { algorithms: ['HS256'] });
// SECURE: For RSA keys, only allow RS algorithms
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
// SECURE: Never allow 'none' algorithm in production
const allowedAlgorithms = process.env.NODE_ENV === 'test'
? ['HS256', 'none'] : ['HS256'];- Always specify the algorithms parameter
- Never include 'none' in production algorithms
- Use different validation for symmetric vs asymmetric keys
- Validate algorithm in JWT header matches expectation
- Implement algorithm allowlist, not blocklist
Storing Sensitive Data in JWTscritical
JWTs are encoded (base64), not encrypted. Any sensitive data in the payload is visible to anyone who has the token.
// VULNERABLE: NEVER put sensitive data in JWTs
const payload = {
userId: 123,
password: 'user-password', // Visible to anyone!
ssn: '123-45-6789', // Major privacy violation
creditCard: '1234-5678-9012-3456', // Financial data exposed
apiSecret: 'internal-api-key', // Internal secrets leaked
email: '[email protected]',
role: 'admin'
};// SECURE: Only include non-sensitive, verifiable data
const payload = {
sub: 'user123', // User identifier (non-sequential)
username: 'johndoe', // Public information
role: 'admin', // Authorization info
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (60 * 15), // 15 min
jti: randomUUID() // Token ID for revocation
};
// Store sensitive data server-side, reference by user ID- Never include passwords or password hashes
- Don't put SSN, PII, or financial data in tokens
- Avoid API keys or internal secrets
- Use non-sequential user IDs
- Keep payload minimal - only authorization data
- Encrypt JWTs if sensitive data is required (JWE)
Ignoring Token Expirationcritical
Not setting expiration times or not validating them properly allows tokens to live forever, dramatically increasing attack windows.
// VULNERABLE: No expiration set
const token = jwt.sign(payload, secret);
// VULNERABLE: Not validating expiration
const decoded = jwt.decode(token); // Doesn't verify!
console.log(decoded.userId);
// VULNERABLE: Extremely long expiration
const token = jwt.sign(payload, secret, {
expiresIn: '10y' // 10 years!
});// SECURE: Always set appropriate expiration
const accessToken = jwt.sign(payload, secret, {
expiresIn: '15m' // 15 minutes for access tokens
});
const refreshToken = jwt.sign(refreshPayload, secret, {
expiresIn: '7d' // 7 days for refresh tokens
});
// SECURE: Always verify, which includes expiration check
try {
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256']
});
// Token is valid and not expired
} catch (error) {
// Handle expired or invalid token
return res.status(401).json({ error: 'Token invalid' });
}- Set short expirations (15-60 minutes) for access tokens
- Use refresh tokens for longer sessions
- Always use jwt.verify(), never jwt.decode() for auth
- Implement token refresh flow
- Consider JTI-based token revocation for immediate invalidation
High-Risk Implementation Errors
Insecure Token Storagehigh
Storing JWTs in localStorage or other client-side storage vulnerable to XSS attacks.
// VULNERABLE to XSS attacks
localStorage.setItem('jwt', token);
sessionStorage.setItem('jwt', token);
// VULNERABLE: Accessible to malicious scripts
document.cookie = `jwt=${token}`;
// VULNERABLE: In URL parameters - logged everywhere
window.location = `/dashboard?token=${token}`;// SECURE: httpOnly cookie (preferred)
res.cookie('jwt', token, {
httpOnly: true, // Not accessible to JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 15 * 60 * 1000 // 15 minutes
});
// SECURE: Authorization header (for SPAs)
fetch('/api/data', {
headers: {
'Authorization': `Bearer ${token}`
}
});
// SECURE: Store in memory only (most secure for SPAs)
let authToken = null; // In-memory storage- Use httpOnly, secure, sameSite cookies when possible
- For SPAs, store tokens in memory, not localStorage
- Implement token refresh flow for short-lived tokens
- Never include tokens in URLs
- Use CSP headers to prevent XSS
Missing HTTPS/TLS Protectionhigh
Transmitting JWTs over unencrypted HTTP connections exposes tokens to interception.
// VULNERABLE: HTTP transmission exposes tokens
fetch('http://api.example.com/login', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
// VULNERABLE: Mixed content vulnerabilities
const API_URL = 'http://api.example.com'; // HTTP API
const FRONTEND_URL = 'https://app.example.com'; // HTTPS frontend// SECURE: Always enforce HTTPS
const API_URL = 'https://api.example.com';
// SECURE: HSTS headers
app.use((req, res, next) => {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
next();
});
// SECURE: Redirect HTTP to HTTPS
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https') {
return res.redirect(`https://${req.header('host')}${req.url}`);
}
next();
});- Enforce HTTPS in production everywhere
- Set HSTS headers with long max-age
- Use secure cookie flags
- Implement certificate pinning for mobile apps
- Monitor for mixed content warnings
Insufficient Token Validationhigh
Not validating all required claims or accepting tokens from unexpected sources.
// VULNERABLE: Minimal validation
const decoded = jwt.verify(token, secret);
const userId = decoded.sub; // Trusting without validation
// VULNERABLE: Not checking issuer/audience
// Accepts tokens from any issuer
// VULNERABLE: Not validating custom claims
if (decoded.role === 'admin') {
// Allowing any role claim without verification
}// SECURE: Comprehensive validation
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'your-app.com',
audience: 'api.your-app.com',
maxAge: '15m'
});
// SECURE: Validate required claims
if (!decoded.sub || !decoded.role) {
throw new Error('Missing required claims');
}
// SECURE: Validate claim values
const allowedRoles = ['user', 'admin', 'moderator'];
if (!allowedRoles.includes(decoded.role)) {
throw new Error('Invalid role claim');
}
// SECURE: Additional security checks
if (decoded.role === 'admin' && !isFromTrustedIP(req.ip)) {
throw new Error('Admin access from untrusted location');
}- Validate issuer (iss) and audience (aud) claims
- Check all required claims are present
- Validate claim values against allowlists
- Implement additional context validation
- Log and monitor validation failures
No Token Revocation Strategyhigh
Once issued, JWTs cannot be revoked without additional infrastructure, making it impossible to immediately invalidate compromised tokens.
// VULNERABLE: No way to revoke tokens
app.post('/logout', (req, res) => {
// JWT tokens remain valid until expiration
res.json({ message: 'Logged out' });
});
// VULNERABLE: Compromised account with no immediate remedy
app.post('/change-password', async (req, res) => {
await updatePassword(userId, newPassword);
// Old JWT tokens still work!
res.json({ message: 'Password changed' });
});// SECURE: Implement token blacklist
const blacklistedTokens = new Set(); // Or use Redis
app.post('/logout', authenticateToken, (req, res) => {
blacklistedTokens.add(req.token.jti); // Blacklist by JWT ID
res.json({ message: 'Logged out successfully' });
});
// SECURE: Check blacklist on each request
function authenticateToken(req, res, next) {
const token = extractToken(req);
const decoded = jwt.verify(token, secret);
if (blacklistedTokens.has(decoded.jti)) {
return res.status(401).json({ error: 'Token revoked' });
}
req.user = decoded;
next();
}
// SECURE: Implement refresh token rotation
app.post('/refresh', (req, res) => {
const refreshToken = req.body.refreshToken;
// Validate refresh token and issue new access token
// Invalidate old refresh token (rotation)
});- Implement JWT ID (jti) based blacklisting
- Use Redis or database for scalable blacklist storage
- Implement refresh token rotation
- Consider short-lived tokens with refresh flow
- Provide emergency token revocation endpoints
Common Implementation Pitfalls
Clock Skew and Timing Issuesmedium
Not accounting for clock differences between servers can cause valid tokens to be rejected or invalid tokens to be accepted.
// VULNERABLE: Strict timestamp validation
const decoded = jwt.verify(token, secret);
if (decoded.iat > Date.now() / 1000) {
throw new Error('Token from future');
}
// VULNERABLE: No clock skew tolerance
if (decoded.nbf && decoded.nbf > Date.now() / 1000) {
throw new Error('Token not yet valid');
}// SECURE: Allow for clock skew
const clockSkew = 60; // 60 seconds tolerance
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
clockTolerance: clockSkew
});
// SECURE: Manual validation with tolerance
const now = Math.floor(Date.now() / 1000);
if (decoded.nbf && (decoded.nbf - clockSkew) > now) {
throw new Error('Token not yet valid');
}
// SECURE: Server time synchronization
// Ensure all servers use NTP for time sync- Set clockTolerance in JWT libraries (30-60 seconds)
- Synchronize server clocks with NTP
- Monitor time drift between servers
- Use UTC for all timestamp calculations
- Log timing validation failures for debugging
Oversized JWT Tokensmedium
Including too much data in JWTs creates large tokens that impact performance and may hit browser/server limits.
// VULNERABLE: Including large data sets
const payload = {
userId: 123,
preferences: { /* 2KB of user preferences */ },
permissions: [ /* 100 permission objects */ ],
profile: {
bio: "Very long biography...", // Large strings
avatar: "data:image/jpeg;base64,..." // Base64 image!
},
auditLog: [ /* Last 50 user actions */ ]
};
// Results in 8KB+ tokens!// SECURE: Minimal payload
const payload = {
sub: 'user123',
role: 'admin',
permissions: ['read', 'write'], // Just permission names
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (15 * 60)
};
// SECURE: Store large data server-side
const userPreferences = await getUserPrefs(userId);
const userProfile = await getUserProfile(userId);
// SECURE: Reference by ID, fetch when needed
const tokenPayload = {
sub: userId,
role: userRole,
prefVersion: userPrefVersion // Version for cache invalidation
};- Keep payloads under 1KB when possible
- Store large data server-side, reference by ID
- Use version numbers for cache invalidation
- Paginate permissions instead of including all
- Consider JWE (encryption) for larger payloads
Poor Error Handlingmedium
Not properly handling JWT validation errors can leak information to attackers or create poor user experience.
// VULNERABLE: Exposing internal error details
try {
const decoded = jwt.verify(token, secret);
} catch (error) {
// Leaks implementation details
res.status(401).json({
error: error.message, // "jwt signature is invalid"
stack: error.stack, // Reveals code structure
secret: secret // NEVER expose secrets!
});
}
// VULNERABLE: Generic error handling
app.use((error, req, res, next) => {
res.status(500).json({ error: 'Something went wrong' });
// No logging, can't debug issues
});// SECURE error handling
try {
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256']
});
req.user = decoded;
next();
} catch (error) {
// Log detailed error internally
logger.warn('JWT validation failed', {
error: error.message,
ip: req.ip,
userAgent: req.get('User-Agent'),
tokenPrefix: token ? token.substring(0, 20) : 'missing'
});
// Return generic error to client
return res.status(401).json({
error: 'Authentication failed',
code: 'INVALID_TOKEN'
});
}
// SECURE: Differentiate error types for better UX
function handleJWTError(error, res) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Token expired',
code: 'TOKEN_EXPIRED'
});
}
// Generic response for other errors
return res.status(401).json({
error: 'Invalid token',
code: 'INVALID_TOKEN'
});
}- Log detailed errors server-side only
- Return generic error messages to clients
- Use error codes for client-side handling
- Implement proper monitoring and alerting
- Never expose secrets or internal paths
Testing Your JWT Implementation
Security Test Cases
- Expired Token Test: Verify expired tokens are rejected
- Invalid Signature Test: Modify token signature and verify rejection
- Algorithm Confusion: Try changing algorithm in header
- None Algorithm: Test tokens with "alg": "none"
- Malformed Token: Send invalid base64 or JSON
- Missing Claims: Remove required claims and test
Security Tools
jwt.ioDecode and verify JWTs onlineOWASP ZAPSecurity testing proxy with JWT supportBurp SuiteProfessional security testing platformjwt-crackerTest JWT secret strength