I’ve debugged hundreds of JWT validation bugs in production - most stem from skipping one critical step. JSON Web Tokens are the backbone of modern OAuth 2.0 auth, and getting validation right is non-negotiable.
Visual Overview:
sequenceDiagram
participant User
participant App as Client App
participant AuthServer as Authorization Server
participant Resource as Resource Server
User->>App: 1. Click Login
App->>AuthServer: 2. Authorization Request
AuthServer->>User: 3. Login Page
User->>AuthServer: 4. Authenticate
AuthServer->>App: 5. Authorization Code
App->>AuthServer: 6. Exchange Code for Token
AuthServer->>App: 7. Access Token + Refresh Token
App->>Resource: 8. API Request with Token
Resource->>App: 9. Protected Resource
Why This Matters
According to OWASP’s API Security Top 10, broken authentication consistently ranks in the top 3 vulnerabilities. JWT validation is your first line of defense. Skip signature verification? You’re accepting forged tokens. Ignore expiration? Attackers replay stolen tokens indefinitely.
I’ve seen a fintech app lose $2M because they decoded JWTs without verifying signatures. The attacker modified the sub claim from their user ID to an admin’s ID, base64-encoded it back, and had full admin access.
What is a JWT?
A JWT is a compact, URL-safe token with three base64-encoded parts separated by dots:
Structure: Header.Payload.Signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkhvdXBpbmciLCJpYXQiOjE1MTYyMzkwMjJ9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Decode it (try it yourself at jwt.io):
Header
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-2023-01"
}
Payload
{
"sub": "1234567890",
"name": "Houping",
"email": "[email protected]",
"iat": 1516239022,
"exp": 1516242622,
"iss": "https://auth.example.com",
"aud": "api.example.com"
}
Signature
Verifies integrity - this is what you MUST validate
How to Decode a JWT (The Easy Part)
Decoding is simple - it’s just base64 decoding. The payload is readable by anyone:
# Decode JWT manually (no verification)
echo "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkhvdXBpbmciLCJpYXQiOjE1MTYyMzkwMjJ9" | base64 -d
# Output: {"sub":"1234567890","name":"Houping","iat":1516239022}
In Node.js:
const jwt = require('jsonwebtoken');
// ⚠️ ONLY decodes - does NOT verify signature
const decoded = jwt.decode(token);
console.log(decoded);
// { sub: '1234567890', name: 'Houping', iat: 1516239022 }
CRITICAL: Decoding alone is useless for security. Anyone can decode a JWT and read the claims. The security comes from validation.
JWT Validation: The Critical Steps You Can’t Skip
I’ve seen production systems that only decoded JWTs without validation. Here’s what you MUST check:
Step 1: Verify Signature
Why: Prevents token forgery. Without this, attackers can create fake tokens.
How: Use the public key from your authorization server’s JWKS endpoint.
const jwksClient = require('jwks-rsa');
const jwt = require('jsonwebtoken');
// Fetch public key from JWKS endpoint
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json'
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
}
// Verify signature
jwt.verify(token, getKey, { algorithms: ['RS256'] }, (err, decoded) => {
if (err) {
console.error('Invalid signature:', err.message);
return;
}
console.log('Valid token:', decoded);
});
Step 2: Check Expiration (exp)
Why: Prevents replay attacks with stolen tokens.
How: Compare exp claim with current time.
jwt.verify(token, publicKey, { algorithms: ['RS256'] }, (err, decoded) => {
if (err && err.name === 'TokenExpiredError') {
console.error('Token expired at:', err.expiredAt);
// Return 401 Unauthorized
return;
}
// Token is valid and not expired
console.log('Token expires at:', new Date(decoded.exp * 1000));
});
Pro tip: Add a 5-minute clock skew tolerance to handle time synchronization issues between servers:
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
clockTolerance: 300 // 5 minutes
}, callback);
Step 3: Validate Audience (aud)
Why: Prevents token misuse. A token issued for api.example.com shouldn’t work for admin.example.com.
How:
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience: 'api.example.com' // Must match aud claim
}, (err, decoded) => {
if (err && err.name === 'JsonWebTokenError') {
console.error('Invalid audience:', err.message);
return;
}
// Token is valid for this audience
});
Step 4: Validate Issuer (iss)
Why: Ensures token came from your trusted authorization server, not a rogue server.
How:
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com' // Must match iss claim
}, callback);
Step 5: Check Algorithm (alg)
Why: Prevents the “none” algorithm attack where attackers remove the signature entirely.
How:
// ✅ GOOD: Explicitly whitelist allowed algorithms
jwt.verify(token, publicKey, {
algorithms: ['RS256', 'RS384', 'RS512'] // Only allow RSA
}, callback);
// ❌ BAD: Trusting the token's algorithm claim
const header = jwt.decode(token, { complete: true }).header;
jwt.verify(token, publicKey, { algorithms: [header.alg] }); // VULNERABLE!
Step 6: Validate Custom Claims
Why: Your business logic may require specific claims (roles, scopes, permissions).
How:
// Java example with Spring Security
@PreAuthorize("hasAuthority('SCOPE_read:users')")
public List<User> getUsers() {
// Verify token contains required scope
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
if (!authorities.stream().anyMatch(a -> a.getAuthority().equals("SCOPE_read:users"))) {
throw new AccessDeniedException("Insufficient scope");
}
return userService.findAll();
}
Common JWT Validation Bugs I’ve Debugged 100+ Times
Issue 1: “Invalid Signature” Error
What you see:
JsonWebTokenError: invalid signature
Root causes:
-
Wrong public key (90% of cases)
- Using development keys in production
- Key rotation not handled
- Fetching from wrong JWKS endpoint
-
Algorithm mismatch
- Token signed with RS256, validating with HS256
- Token signed with ES256, validating with RS256
Fix it:
# 1. Verify the token's algorithm and kid (key ID)
echo $TOKEN | cut -d'.' -f1 | base64 -d
# {"alg":"RS256","kid":"key-2023-01"}
# 2. Fetch the JWKS endpoint and find matching kid
curl https://auth.example.com/.well-known/jwks.json | jq '.keys[] | select(.kid=="key-2023-01")'
# 3. Verify algorithm matches in your code
jwt.verify(token, publicKey, { algorithms: ['RS256'] }, callback);
Issue 2: “jwt expired” Error
Problem: Tokens expire too quickly, causing legitimate requests to fail.
Solutions:
Option 1: Increase token lifetime (authorization server)
# ForgeRock AM example
oauth2:
accessTokenLifetime: 3600 # 1 hour instead of 300 seconds
idTokenLifetime: 3600
Option 2: Implement token refresh
async function callApiWithRefresh(endpoint) {
try {
return await fetch(endpoint, {
headers: { Authorization: `Bearer ${accessToken}` }
});
} catch (err) {
if (err.status === 401) {
// Token expired, refresh it
accessToken = await refreshAccessToken(refreshToken);
// Retry with new token
return fetch(endpoint, {
headers: { Authorization: `Bearer ${accessToken}` }
});
}
throw err;
}
}
Issue 3: “jwt audience invalid” Error
Symptom:
JsonWebTokenError: jwt audience invalid. expected: api.example.com
Root cause: Token was issued for a different audience.
Fix:
// Check what audience the token was issued for
const decoded = jwt.decode(token);
console.log('Token aud:', decoded.aud);
// Output: "admin.example.com"
// Either:
// 1. Request token with correct audience
const tokenResponse = await fetch('https://auth.example.com/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
audience: 'api.example.com', // Correct audience
client_id: 'your-client-id',
client_secret: 'your-client-secret'
})
});
// 2. Or validate with correct audience value
jwt.verify(token, publicKey, {
audience: ['api.example.com', 'admin.example.com'] // Accept multiple
}, callback);
Complete JWT Validation Implementation
Here’s a production-ready Express.js middleware I use in 50+ projects:
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
// Configure JWKS client
const jwks = jwksClient({
jwksUri: process.env.JWKS_URI || 'https://auth.example.com/.well-known/jwks.json',
cache: true,
cacheMaxAge: 86400000 // 24 hours
});
// Get signing key
function getKey(header, callback) {
jwks.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
}
// Validation middleware
function validateJWT(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
}
const token = authHeader.substring(7);
jwt.verify(token, getKey, {
algorithms: ['RS256', 'RS384', 'RS512'],
audience: process.env.JWT_AUDIENCE || 'api.example.com',
issuer: process.env.JWT_ISSUER || 'https://auth.example.com',
clockTolerance: 300 // 5 minutes
}, (err, decoded) => {
if (err) {
console.error('JWT validation failed:', err.message);
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', expiredAt: err.expiredAt });
}
return res.status(403).json({ error: 'Invalid token', details: err.message });
}
// Attach decoded token to request
req.user = decoded;
next();
});
}
// Usage
app.get('/api/users', validateJWT, (req, res) => {
// req.user contains validated JWT claims
console.log('User:', req.user.sub);
res.json({ users: [] });
});
Security Best Practices
Do’s
1. Always validate signatures
// ✅ GOOD
jwt.verify(token, publicKey, { algorithms: ['RS256'] }, callback);
// ❌ BAD - just decoding
const decoded = jwt.decode(token); // No verification!
2. Use JWKS endpoints for key management
// ✅ GOOD - Automatic key rotation handling
const jwks = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true
});
// ❌ BAD - Hardcoded keys
const publicKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg...";
3. Implement token revocation checking
For high-security scenarios, check a revocation list:
async function validateJWT(token) {
// Step 1: Verify signature and claims
const decoded = await jwt.verify(token, publicKey, options);
// Step 2: Check if token is revoked
const isRevoked = await redis.get(`revoked:${decoded.jti}`);
if (isRevoked) {
throw new Error('Token has been revoked');
}
return decoded;
}
Don’ts
1. Never skip signature verification
// ❌ NEVER DO THIS IN PRODUCTION
const decoded = jwt.decode(token);
if (decoded.exp > Date.now() / 1000) {
// Use decoded claims
}
// Attacker can forge any claims!
2. Don’t trust the algorithm claim
// ❌ BAD - Algorithm confusion attack
const header = jwt.decode(token, { complete: true }).header;
jwt.verify(token, secret, { algorithms: [header.alg] });
// ✅ GOOD - Explicit whitelist
jwt.verify(token, secret, { algorithms: ['RS256'] });
3. Don’t use symmetric algorithms for public APIs
// ❌ BAD - HS256 with shared secret
jwt.verify(token, sharedSecret, { algorithms: ['HS256'] });
// Anyone with the secret can create valid tokens
// ✅ GOOD - RS256 with public/private keys
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
// Only the authorization server can sign tokens
Real-World Use Case: Microservices API Gateway
I implemented JWT validation for a fintech platform processing 100K API requests/hour:
Architecture
flowchart LR
A["Client<br/>(SPA)"] --> B["API Gateway<br/>(JWT Validator)"]
B --> C["Microservices<br/>(Payments, Users, etc)"]
B --> D["Auth Server<br/>(ForgeRock)<br/>+ JWKS Cache"]
style A fill:#667eea,color:#fff
style B fill:#ed8936,color:#fff
style C fill:#48bb78,color:#fff
style D fill:#764ba2,color:#fff
Implementation Details
1. JWKS Caching (Critical for performance)
const NodeCache = require('node-cache');
const jwksCache = new NodeCache({ stdTTL: 86400 }); // 24 hours
async function getPublicKey(kid) {
// Check cache first
const cached = jwksCache.get(kid);
if (cached) return cached;
// Fetch from JWKS endpoint
const response = await fetch('https://auth.example.com/.well-known/jwks.json');
const jwks = await response.json();
const key = jwks.keys.find(k => k.kid === kid);
if (!key) throw new Error(`Key ${kid} not found in JWKS`);
// Cache for 24 hours
jwksCache.set(kid, key);
return key;
}
2. Rate Limiting by Subject
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
keyGenerator: (req) => req.user.sub, // Rate limit per user
max: 100, // 100 requests
windowMs: 60000 // per minute
});
app.use('/api/', validateJWT, limiter);
Results
- 99.95% uptime maintained
- <5ms JWT validation latency (with caching)
- Zero successful token forgery attacks in 2 years
- Passed SOC 2 Type II audit with no auth findings
Comparison: JWT vs Opaque Tokens
| Feature | JWT (Self-contained) | Opaque Tokens (Reference) |
|---|---|---|
| Validation | Local (fast) | Remote call to auth server (slower) |
| Size | Large (1-2KB) | Small (32-128 bytes) |
| Revocation | Difficult (need blacklist) | Easy (delete from DB) |
| Payload Visibility | Readable by anyone | Opaque, server-side only |
| Best For | Stateless APIs, microservices | Highly sensitive data, need instant revocation |
When to use JWTs:
- High-traffic APIs where latency matters
- Stateless microservices architecture
- Offline validation needed (mobile apps)
When to use opaque tokens:
- Need instant revocation (banking, admin panels)
- Highly sensitive claims (PII, medical data)
- Short-lived sessions (< 5 minutes)
Implementation Checklist
Before going to production:
- Signature verification implemented with public key from JWKS
- Expiration (
exp) checked on every request - Audience (
aud) validated against expected value - Issuer (
iss) validated against trusted authority - Algorithm explicitly whitelisted (no
alg: none) - Clock skew tolerance configured (5 minutes recommended)
- JWKS endpoint cached (24 hours TTL)
- Custom claims validated (scopes, roles, permissions)
- Token revocation checking for high-security endpoints
- Error handling returns appropriate HTTP status codes
- Logging configured for failed validations
- Rate limiting implemented per user (
subclaim)
Key Takeaways
Critical security rules:
- Always verify signatures - Decoding alone is useless
- Whitelist algorithms - Prevent algorithm confusion attacks
- Validate all standard claims - exp, aud, iss, iat
- Cache JWKS keys - Don’t fetch on every request
- Use RS256 for APIs - Asymmetric is safer than HS256
Common mistakes to avoid:
- Trusting decoded claims without verification
- Not handling token expiration gracefully
- Using symmetric algorithms (HS256) for public APIs
- Skipping audience/issuer validation
- Not implementing proper error handling
Next steps:
- Audit your current JWT validation code
- Implement missing validations (aud, iss, algorithm)
- Set up JWKS caching
- Add monitoring for validation failures
- Test with expired/invalid tokens
👉 Related: Understanding the Authorization Code Flow in OAuth 2.0
👉 Related: How PKCE Enhances Security in Authorization Code Flow