← Back to Learn

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

Secret Generation
// ❌ 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).

Algorithm Verification
// ❌ 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

Token Expiration
// 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.

Token Transmission
// ❌ 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

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.

Storage Options
// ✅ 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

Token Refresh Flow
// 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

Payload Content
// ❌ 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

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

Token Revocation
// 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

Header Validation
// 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

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

Popular

Comprehensive antivirus and cybersecurity solutions for individuals and businesses. Protect your digital life with industry-leading threat detection.

Learn More

Affiliate Link