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):

{
  "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:

  1. Wrong public key (90% of cases)

    • Using development keys in production
    • Key rotation not handled
    • Fetching from wrong JWKS endpoint
  2. 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 (sub claim)

Key Takeaways

Critical security rules:

  1. Always verify signatures - Decoding alone is useless
  2. Whitelist algorithms - Prevent algorithm confusion attacks
  3. Validate all standard claims - exp, aud, iss, iat
  4. Cache JWKS keys - Don’t fetch on every request
  5. 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:

  1. Audit your current JWT validation code
  2. Implement missing validations (aud, iss, algorithm)
  3. Set up JWKS caching
  4. Add monitoring for validation failures
  5. Test with expired/invalid tokens

👉 Related: Understanding the Authorization Code Flow in OAuth 2.0

👉 Related: How PKCE Enhances Security in Authorization Code Flow