Guide · Developer security
How to Validate JWT Tokens Securely
A comprehensive guide to properly validating JSON Web Tokens, including signature verification, claims validation, and preventing common security vulnerabilities.
Why Proper JWT Validation Matters
JWT validation is your primary defense against token-based attacks. Improper validation can lead to authentication bypass, privilege escalation, and unauthorized access. This guide covers every aspect of secure JWT validation.
Critical Security Warning: Never trust the contents of a JWT without properly validating its signature and claims. A decoded JWT without verification is just untrustworthy data that anyone could have created.
The JWT Validation Process
Validation Steps Overview
- Format validation - Ensure proper JWT structure
- Header validation - Check algorithm and token type
- Signature verification - Cryptographic validation
- Claims validation - Verify standard and custom claims
- Context validation - Application-specific checks
Common Validation Vulnerabilities
- Algorithm confusion attacks - Accepting unexpected algorithms
- Signature bypass - Using decode instead of verify
- Missing expiration checks - Accepting expired tokens
- Insufficient claim validation - Not checking issuer/audience
- Time validation issues - Ignoring nbf, iat claims
Step 1: Format Validation
JWT Structure Requirements
A valid JWT must have exactly three parts separated by dots:
JWT format
// Valid JWT format
header.payload.signature
// Example
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cBasic Format Validation
Node.js
function validateJWTFormat(token) {
if (!token || typeof token !== 'string') {
throw new Error('Token must be a non-empty string');
}
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT format: must have 3 parts');
}
// Validate each part is valid base64url
for (let i = 0; i < 3; i++) {
try {
atob(parts[i].replace(/-/g, '+').replace(/_/g, '/'));
} catch (e) {
throw new Error(`Invalid JWT part ${i + 1}: not valid base64url`);
}
}
return true;
}Note: Most JWT libraries handle format validation automatically, but it's important to understand what's happening under the hood.
Step 2: Header Validation
Critical Header Checks
The JWT header contains metadata about the token that must be validated:
Node.js
function validateJWTHeader(header) {
// Parse the header
const headerObj = JSON.parse(atob(header));
// 1. Check token type
if (headerObj.typ && headerObj.typ !== 'JWT') {
throw new Error(`Unexpected token type: ${headerObj.typ}`);
}
// 2. Validate algorithm
const allowedAlgorithms = ['HS256', 'HS384', 'HS512', 'RS256', 'ES256'];
if (!allowedAlgorithms.includes(headerObj.alg)) {
throw new Error(`Unsupported algorithm: ${headerObj.alg}`);
}
// 3. Prevent algorithm confusion attacks
if (headerObj.alg === 'none') {
throw new Error('Algorithm "none" is not allowed');
}
return headerObj;
}Algorithm Whitelist Approach
Recommended algorithm configurations:
HS256For single-service applicationsRS256For distributed services with public key verificationES256For high-performance applicationsStep 3: Signature Verification
HMAC Signature Verification (HS256)
Node.js
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
// Secure HMAC verification
function verifyHMACToken(token, secret) {
try {
// Always specify allowed algorithms explicitly
const payload = jwt.verify(token, secret, {
algorithms: ['HS256'], // Explicit algorithm whitelist
clockTolerance: 30, // Allow 30 seconds clock drift
});
return payload;
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new Error('Token has expired');
}
if (error.name === 'JsonWebTokenError') {
throw new Error(`Invalid token: ${error.message}`);
}
throw error;
}
}RSA Signature Verification (RS256)
Node.js
const fs = require('fs');
function verifyRSAToken(token, publicKeyPath) {
try {
const publicKey = fs.readFileSync(publicKeyPath, 'utf8');
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Only allow RS256
issuer: 'https://your-auth-server.com',
audience: 'your-api-id',
});
return payload;
} catch (error) {
// Handle specific error types
switch (error.name) {
case 'TokenExpiredError':
throw new Error('Token expired');
case 'NotBeforeError':
throw new Error('Token not yet active');
case 'JsonWebTokenError':
throw new Error(`Token validation failed: ${error.message}`);
default:
throw new Error('Unknown token validation error');
}
}
}Pro tip: Never use
jwt.decode() in production. It returns the payload without verification. Always use jwt.verify() which validates the signature before returning the payload.Step 4: Claims Validation
Standard Claims Validation
JWT defines several standard claims that should be validated:
expExpiration time (Unix timestamp)nbfNot before time (Unix timestamp)iatIssued at time (Unix timestamp)issIssuer (who created the token)audAudience (who the token is for)subSubject (user/resource identifier)jtiJWT ID (unique token identifier)Comprehensive Claims Validation
Node.js
function validateJWTClaims(payload, options = {}) {
const now = Math.floor(Date.now() / 1000);
const clockTolerance = options.clockTolerance || 30;
// 1. Expiration validation (exp)
if (payload.exp && payload.exp < (now - clockTolerance)) {
throw new Error('Token has expired');
}
// 2. Not before validation (nbf)
if (payload.nbf && payload.nbf > (now + clockTolerance)) {
throw new Error('Token is not yet active');
}
// 3. Issued at validation (iat)
if (payload.iat && payload.iat > (now + clockTolerance)) {
throw new Error('Token issued in the future');
}
// 4. Issuer validation (iss)
if (options.issuer && payload.iss !== options.issuer) {
throw new Error(`Invalid issuer: expected ${options.issuer}, got ${payload.iss}`);
}
// 5. Audience validation (aud)
if (options.audience) {
if (Array.isArray(payload.aud)) {
if (!payload.aud.includes(options.audience)) {
throw new Error(`Invalid audience: token not intended for ${options.audience}`);
}
} else if (payload.aud !== options.audience) {
throw new Error(`Invalid audience: expected ${options.audience}, got ${payload.aud}`);
}
}
// 6. Subject validation (sub)
if (options.subject && payload.sub !== options.subject) {
throw new Error(`Invalid subject: expected ${options.subject}, got ${payload.sub}`);
}
return true;
}Custom Claims Validation
Node.js
function validateCustomClaims(payload, requirements) {
// Validate user roles
if (requirements.requiredRoles) {
const userRoles = payload.roles || [];
const hasRequired = requirements.requiredRoles.some(role =>
userRoles.includes(role)
);
if (!hasRequired) {
throw new Error('Insufficient permissions');
}
}
// Validate permissions
if (requirements.requiredPermissions) {
const userPermissions = payload.permissions || [];
const hasAllPermissions = requirements.requiredPermissions.every(perm =>
userPermissions.includes(perm)
);
if (!hasAllPermissions) {
throw new Error('Missing required permissions');
}
}
// Validate scope (OAuth2)
if (requirements.requiredScope) {
const tokenScope = (payload.scope || '').split(' ');
const hasScope = requirements.requiredScope.every(scope =>
tokenScope.includes(scope)
);
if (!hasScope) {
throw new Error('Insufficient scope');
}
}
// Validate token type
if (requirements.tokenType && payload.token_type !== requirements.tokenType) {
throw new Error(`Invalid token type: expected ${requirements.tokenType}`);
}
}Step 5: Context Validation
Application-Specific Validation
Beyond standard claims, you may need application-specific validation:
Node.js
async function validateContextualClaims(payload, request) {
// 1. IP address validation
if (payload.ip && request.ip !== payload.ip) {
throw new Error('Token bound to different IP address');
}
// 2. User agent validation
if (payload.user_agent && request.headers['user-agent'] !== payload.user_agent) {
console.warn('User agent mismatch - possible token theft');
// Decide whether to reject or just log
}
// 3. Session validation
if (payload.session_id) {
const sessionValid = await validateSession(payload.session_id);
if (!sessionValid) {
throw new Error('Session has been revoked');
}
}
// 4. Rate limiting
if (payload.rate_limit) {
await checkRateLimit(payload.sub, payload.rate_limit);
}
// 5. Feature flags
if (payload.features) {
const enabledFeatures = await getEnabledFeatures(payload.sub);
payload.features = payload.features.filter(feature =>
enabledFeatures.includes(feature)
);
}
}Token Blacklist Validation
Node.js
const redis = require('redis');
const client = redis.createClient();
async function validateTokenNotBlacklisted(payload) {
// Check if token is blacklisted by JTI
if (payload.jti) {
const isBlacklisted = await client.get(`blacklist:${payload.jti}`);
if (isBlacklisted) {
throw new Error('Token has been revoked');
}
}
// Check if all user tokens are revoked
if (payload.sub) {
const userTokensRevoked = await client.get(`revoked_user:${payload.sub}`);
if (userTokensRevoked && parseInt(userTokensRevoked) > payload.iat) {
throw new Error('All user tokens have been revoked');
}
}
}Complete Validation Implementation
Production-Ready Validation Function
Node.js
const jwt = require('jsonwebtoken');
class JWTValidator {
constructor(options) {
this.secret = options.secret;
this.publicKey = options.publicKey;
this.algorithm = options.algorithm || 'HS256';
this.issuer = options.issuer;
this.audience = options.audience;
this.clockTolerance = options.clockTolerance || 30;
}
async validate(token, additionalOptions = {}) {
try {
// Step 1: Basic format validation
this.validateFormat(token);
// Step 2: Signature verification with claims validation
const payload = this.verifySignature(token, additionalOptions);
// Step 3: Additional custom validations
await this.validateCustomClaims(payload, additionalOptions);
// Step 4: Context-specific validation
await this.validateContext(payload, additionalOptions);
return {
valid: true,
payload,
header: jwt.decode(token, { complete: true }).header
};
} catch (error) {
return {
valid: false,
error: error.message,
type: this.classifyError(error)
};
}
}
validateFormat(token) {
if (!token || typeof token !== 'string') {
throw new Error('Invalid token format');
}
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT structure');
}
}
verifySignature(token, options) {
const verificationOptions = {
algorithms: [this.algorithm],
issuer: this.issuer,
audience: this.audience,
clockTolerance: this.clockTolerance,
...options.jwtOptions
};
const key = this.algorithm.startsWith('HS') ? this.secret : this.publicKey;
return jwt.verify(token, key, verificationOptions);
}
async validateCustomClaims(payload, options) {
// Implement your custom claim validation logic
if (options.requiredRoles) {
const userRoles = payload.roles || [];
if (!options.requiredRoles.some(role => userRoles.includes(role))) {
throw new Error('Insufficient roles');
}
}
}
async validateContext(payload, options) {
// Blacklist check
if (options.checkBlacklist && payload.jti) {
// Implement blacklist checking
}
// Session validation
if (options.validateSession && payload.session_id) {
// Implement session validation
}
}
classifyError(error) {
if (error.name === 'TokenExpiredError') return 'EXPIRED';
if (error.name === 'NotBeforeError') return 'NOT_ACTIVE';
if (error.name === 'JsonWebTokenError') return 'INVALID';
return 'UNKNOWN';
}
}
// Usage example
const validator = new JWTValidator({
secret: process.env.JWT_SECRET,
algorithm: 'HS256',
issuer: 'https://your-app.com',
audience: 'your-api'
});
// Middleware usage
async function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.sendStatus(401);
}
const result = await validator.validate(token, {
requiredRoles: ['user'],
checkBlacklist: true
});
if (!result.valid) {
return res.status(401).json({ error: result.error });
}
req.user = result.payload;
next();
}Error Handling Best Practices
Specific Error Types
TokenExpiredErrorToken has expired (401)NotBeforeErrorToken not yet active (401)JsonWebTokenErrorInvalid signature/format (401)InvalidIssuerWrong issuer (401)InvalidAudienceWrong audience (401)InsufficientScopeMissing permissions (403)Secure Error Responses
Node.js
function handleValidationError(error, req, res) {
// Log detailed error for debugging (server-side only)
console.error('JWT Validation Error:', {
error: error.message,
token: req.headers.authorization?.substring(0, 20) + '...',
ip: req.ip,
userAgent: req.headers['user-agent']
});
// Return generic error to client (don't leak internals)
switch (error.type) {
case 'EXPIRED':
return res.status(401).json({
error: 'Token expired',
code: 'TOKEN_EXPIRED'
});
case 'NOT_ACTIVE':
return res.status(401).json({
error: 'Token not yet valid',
code: 'TOKEN_NOT_ACTIVE'
});
case 'INVALID':
return res.status(401).json({
error: 'Invalid token',
code: 'TOKEN_INVALID'
});
default:
return res.status(401).json({
error: 'Authentication failed',
code: 'AUTH_FAILED'
});
}
}Performance Optimizations
Caching Strategies
- Public key caching: Cache RSA/ECDSA public keys from JWKS endpoints
- Validation result caching: Cache valid tokens with short TTL
- Blacklist caching: Cache blacklist checks in memory
Key Rotation Handling
Node.js
class JWKSValidator {
constructor(jwksUri) {
this.jwksUri = jwksUri;
this.keyCache = new Map();
this.lastFetch = 0;
}
async getKey(kid) {
// Refresh keys if cache is stale
if (Date.now() - this.lastFetch > 300000) { // 5 minutes
await this.refreshKeys();
}
const key = this.keyCache.get(kid);
if (!key) {
// Try one more refresh in case of new key
await this.refreshKeys();
return this.keyCache.get(kid);
}
return key;
}
async refreshKeys() {
try {
const response = await fetch(this.jwksUri);
const jwks = await response.json();
for (const key of jwks.keys) {
this.keyCache.set(key.kid, key);
}
this.lastFetch = Date.now();
} catch (error) {
console.error('Failed to refresh JWKS:', error);
}
}
}Security Checklist
JWT validation security checklist:
Track your progress0 of 12