JWT Security Best Practices
Follow these comprehensive best practices to implement secure JWT-based authentication and authorization in production environments. For enterprise security solutions, consider Bitdefender or Tenable One.
1. Secret & Key Management
// ❌ Bad - Hardcoded, weak secret
const secret = "secret";
const secret = "my-secret-key";
// ✅ Good - Strong, random secret
const secret = crypto.randomBytes(64).toString('hex');
// Minimum 32 bytes (256 bits) for HS256
// ✅ Best - Environment variable with rotation
const secret = process.env.JWT_SECRET;
// Rotate every 90 days
// Use different secrets for different environments- HS256: Use at least 32 characters (256 bits)
- RS256/ES256: Use 2048+ bit keys
- Generate secrets using cryptographically secure random generators
- Store secrets in environment variables or secret management systems (AWS Secrets Manager, HashiCorp Vault)
- Never commit secrets to version control
- Rotate secrets periodically (every 90 days recommended)
- Use different secrets for development, staging, and production
2. Always Verify Algorithm
⚠️ Critical Security Issue
Not verifying the algorithm allows algorithm confusion attacks (e.g., RS256 → HS256).
// ❌ Bad - accepts any algorithm (VULNERABLE!)
jwt.verify(token, secret);
// ✅ Good - explicitly specify allowed algorithms
jwt.verify(token, secret, {
algorithms: ['HS256'] // Only allow HS256
});
// ✅ Best - Use algorithm whitelist
const ALLOWED_ALGORITHMS = ['HS256', 'RS256'];
jwt.verify(token, secret, {
algorithms: ALLOWED_ALGORITHMS
});
// Never accept 'none' algorithm
if (decoded.header.alg === 'none') {
throw new Error('Algorithm "none" not allowed');
}- Always explicitly specify allowed algorithms in verification
- Never accept the "none" algorithm
- Use algorithm whitelists, not blacklists
- For RS256, ensure you're using the public key, not private key for verification
- Validate algorithm matches expected type (symmetric vs asymmetric)
3. Token Expiration Strategy
// Access token - short lived (15 minutes to 1 hour)
const accessToken = jwt.sign(payload, secret, {
expiresIn: '15m', // Short expiration
issuer: 'my-app',
audience: 'my-api'
});
// Refresh token - longer lived (7-30 days)
const refreshToken = jwt.sign(
{ sub: userId, type: 'refresh' },
refreshSecret,
{ expiresIn: '7d' }
);
// Verify expiration
jwt.verify(token, secret, {
algorithms: ['HS256'],
clockTolerance: 60 // Allow 60s clock skew
});- Access tokens: 15 minutes to 1 hour
- Refresh tokens: 7-30 days
- Always set expiration times (exp claim)
- Use refresh tokens for long-lived sessions
- Implement token refresh mechanism
- Handle clock skew (allow 60-300 seconds tolerance)
- Check expiration before processing requests
4. Transport Security
❌ Never Do This
Never pass tokens in URL parameters - they get logged, cached, and leaked in Referer headers.
// ❌ Bad - Token in URL
GET /api/data?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
// ✅ Good - Token in Authorization header
GET /api/data
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
// ✅ Best - HttpOnly cookie (prevents XSS)
res.cookie('token', token, {
httpOnly: true, // Not accessible to JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 900000 // 15 minutes
});- Always use HTTPS in production
- Never pass tokens in URL query parameters
- Use Authorization header with Bearer scheme
- Prefer HttpOnly cookies over localStorage
- Set Secure flag for cookies (HTTPS only)
- Use SameSite attribute for CSRF protection
- Implement CORS properly
5. Comprehensive Claim Validation
// Verify standard claims
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'my-app', // Validate issuer
audience: 'my-api', // Validate audience
clockTolerance: 60 // Allow 60s clock skew
});
// Validate custom claims
const allowedRoles = ['user', 'admin', 'moderator'];
if (!allowedRoles.includes(decoded.role)) {
throw new Error('Invalid role');
}
// Check permissions
const requiredPermission = 'write';
if (!decoded.permissions?.includes(requiredPermission)) {
throw new Error('Insufficient permissions');
}
// Validate against database (for sensitive operations)
const user = await db.users.findById(decoded.sub);
if (!user || user.role !== decoded.role) {
throw new Error('Token claims do not match user');
}- Validate all claims, not just signature
- Use allowlists for claim values (roles, permissions)
- Never trust claims alone for authorization - verify against database for sensitive operations
- Validate issuer (iss) and audience (aud) claims
- Check expiration (exp) and not-before (nbf) claims
- Validate custom claims against schemas
- Sanitize and validate all claim values
6. Token Storage
✅ Recommended: HttpOnly Cookies
HttpOnly cookies prevent XSS attacks from stealing tokens.
❌ Avoid: localStorage/sessionStorage
These are accessible to JavaScript and vulnerable to XSS attacks.
// ✅ Server-side cookie (most secure)
res.cookie('token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 900000
});
// ⚠️ Client-side memory (for SPAs)
// Store in memory variable (cleared on page refresh)
let token = null;
// ❌ localStorage (vulnerable to XSS)
localStorage.setItem('token', token);
// ❌ sessionStorage (vulnerable to XSS)
sessionStorage.setItem('token', token);7. Token Refresh Implementation
// Client-side refresh logic
async function refreshAccessToken() {
const refreshToken = getRefreshToken();
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
credentials: 'include'
});
if (!response.ok) {
// Refresh token expired - redirect to login
redirectToLogin();
return;
}
const { accessToken, refreshToken: newRefreshToken } = await response.json();
storeToken(accessToken);
if (newRefreshToken) {
storeRefreshToken(newRefreshToken);
}
} catch (error) {
redirectToLogin();
}
}
// Server-side refresh endpoint
app.post('/api/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET, {
algorithms: ['HS256']
});
// Check if refresh token is blacklisted
if (await isTokenBlacklisted(refreshToken)) {
return res.status(401).json({ error: 'Token revoked' });
}
// Issue new access token
const newAccessToken = jwt.sign(
{ sub: decoded.sub, role: decoded.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});- Use short-lived access tokens (15-60 minutes)
- Use longer-lived refresh tokens (7-30 days)
- Store refresh tokens securely (HttpOnly cookies)
- Implement automatic token refresh before expiration
- Rotate refresh tokens on use (optional but recommended)
- Blacklist revoked refresh tokens
8. Payload Content Guidelines
// ❌ Never include sensitive data
{
"sub": "user123",
"password": "plaintext123", // ❌
"creditCard": "1234-5678", // ❌
"ssn": "123-45-6789", // ❌
"apiKey": "secret-key", // ❌
"privateKey": "...", // ❌
"email": "user@example.com" // ⚠️ PII - consider carefully
}
// ✅ Only include necessary, non-sensitive claims
{
"sub": "user-id", // User identifier
"name": "John Doe", // Public name
"role": "user", // Role (verify against DB)
"permissions": ["read"], // Permissions
"iat": 1234567890, // Issued at
"exp": 1234567890 // Expiration
}
// For sensitive data, use JWE (JSON Web Encryption)
// Or store in database and reference by ID- Never store passwords, API keys, or secrets in JWT payload
- Avoid storing PII (Personally Identifiable Information) unless necessary
- Keep payload size small (affects performance)
- Use JWE (JSON Web Encryption) for sensitive data if needed
- Store sensitive data in database and reference by ID
- Remember: JWT payload is base64 encoded, not encrypted
9. Algorithm Selection
// Symmetric algorithms (HS256, HS384, HS512)
// ✅ Use for: Single server, internal services
// ⚠️ Requires: Shared secret between signer and verifier
const token = jwt.sign(payload, secret, { algorithm: 'HS256' });
// Asymmetric algorithms (RS256, RS384, RS512, ES256, ES384, ES512)
// ✅ Use for: Distributed systems, microservices, public APIs
// ✅ Benefits: Public key can verify without sharing private key
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
// Verification uses public key
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
// Algorithm recommendations:
// - HS256: Most common, good for single-server apps
// - RS256: Best for distributed systems, APIs
// - ES256: Similar to RS256, smaller key size
// - Never use: 'none' algorithm- HS256: Single server, internal services
- RS256/ES256: Distributed systems, microservices, public APIs
- Use RS256/ES256 when verifier shouldn't have signing key
- Never use the "none" algorithm
- Use at least 2048-bit keys for RSA
- Use at least 256-bit keys for ECDSA
10. Token Revocation & Blacklisting
// Option 1: Token blacklist (Redis/database)
async function revokeToken(token) {
const decoded = jwt.decode(token);
const jti = decoded.jti || hashToken(token);
const expiresAt = decoded.exp * 1000;
// Store in Redis with expiration
await redis.setex(`blacklist:${jti}`, expiresAt, '1');
}
async function isTokenBlacklisted(token) {
const decoded = jwt.decode(token);
const jti = decoded.jti || hashToken(token);
return await redis.exists(`blacklist:${jti}`);
}
// Option 2: Short expiration + refresh tokens
// Access tokens expire quickly (15 min)
// Revoke refresh token on logout
// No blacklist needed for access tokens
// Option 3: Token versioning
{
"sub": "user123",
"tokenVersion": 1 // Increment on password change/logout
}
// Check token version against user record
if (decoded.tokenVersion !== user.tokenVersion) {
throw new Error('Token revoked');
}- Implement logout functionality with token revocation
- Use token blacklist (Redis) for immediate revocation
- Or use short expiration + refresh token rotation
- Use jti (JWT ID) for token identification
- Implement token versioning for password changes
- Check blacklist before processing requests
11. Header Parameter Security
// Validate kid (Key ID) header
if (decoded.header.kid) {
const allowedKids = ['key-1', 'key-2'];
if (!allowedKids.includes(decoded.header.kid)) {
throw new Error('Invalid key ID');
}
// Never use kid directly in file paths or SQL queries
}
// Validate jku (JWK Set URL) header
if (decoded.header.jku) {
const allowedDomains = ['https://trusted-domain.com'];
const url = new URL(decoded.header.jku);
if (!allowedDomains.some(domain => url.origin === domain)) {
throw new Error('Untrusted JWKS URL');
}
// Implement certificate pinning
// Cache JWKS responses
}
// Validate x5u (X.509 URL) header
if (decoded.header.x5u) {
// Similar validation as jku
// Validate certificate chain
// Whitelist trusted URLs
}- Whitelist allowed kid values
- Never use kid in file paths or database queries
- Validate jku URLs against trusted domains
- Implement certificate pinning for JWKS
- Cache JWKS responses to prevent spoofing
- Validate x5u URLs and certificate chains
12. Secure Error Handling
// ❌ Bad - Leaks information
try {
jwt.verify(token, secret);
} catch (error) {
res.status(401).json({
error: 'Invalid token',
details: error.message, // ❌ Leaks algorithm, expiration details
token: token // ❌ Never log tokens
});
}
// ✅ Good - Generic error messages
try {
jwt.verify(token, secret, { algorithms: ['HS256'] });
} catch (error) {
// Log error server-side (without token)
logger.error('Token verification failed', { error: error.message });
// Generic response to client
res.status(401).json({
error: 'Authentication failed'
});
}
// Never log tokens in error messages
// Use constant-time comparison for signatures
// Don't reveal which validation failed- Use generic error messages to clients
- Never log tokens in error messages or logs
- Log errors server-side without sensitive data
- Don't reveal which validation failed
- Use constant-time comparison for signatures
- Return same error format for all authentication failures
13. Testing & Monitoring
- Test all vulnerability scenarios (algorithm confusion, kid injection, etc.)
- Monitor token usage patterns and anomalies
- Set up alerts for suspicious token activity
- Log authentication events (without tokens)
- Monitor token expiration and refresh rates
- Test token revocation and blacklisting
- Perform regular security audits
✅ Security Checklist
- ✓ Strong secrets (32+ characters) stored securely
- ✓ Algorithm whitelist enforced
- ✓ Appropriate token expiration times
- ✓ HTTPS only in production
- ✓ All claims validated
- ✓ HttpOnly cookies for storage
- ✓ Token refresh mechanism implemented
- ✓ No sensitive data in payload
- ✓ Appropriate algorithm selected
- ✓ Token revocation/blacklisting implemented
- ✓ Header parameters validated
- ✓ Secure error handling
- ✓ Monitoring and testing in place
Bitdefender - Advanced Cybersecurity Protection
PopularComprehensive antivirus and cybersecurity solutions for individuals and businesses. Protect your digital life with industry-leading threat detection.
Affiliate Link