Debugging JWT Authentication Errors: A Systematic Guide
JWT Errors Are Cryptic by Default
When JWT authentication fails, you typically get a generic 401 response with little explanation. The token "doesn't work," but the server rarely tells you why. This guide maps common failure patterns to their root causes so you can debug methodically instead of guessing.
Decode the Token First
Before anything else, decode the token and inspect its claims. You need to see what the token contains — and what it doesn't.
// Quick browser decode (verification only — no signature check)
function decodeJWT(token) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('Invalid JWT structure');
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
return payload;
}
Check these fields immediately:
exp— Is the token expired?iss— Does it match what the server expects?aud— Does it include this API's identifier?iat— When was it issued? Is the timestamp plausible?
Error 1: Token Expired
Symptoms
- Server returns 401 with message like
"jwt expired"or"Token is expired" - The token worked recently but stopped
Diagnosis
const payload = decodeJWT(token);
const now = Math.floor(Date.now() / 1000);
const expired = now >= payload.exp;
const expiresAt = new Date(payload.exp * 1000).toISOString();
console.log(`Token expired: ${expired}`);
console.log(`Expires at: ${expiresAt}`);
console.log(`Current time: ${new Date().toISOString()}`);
Common Causes
| Cause | Fix |
|---|---|
| Access token expired normally | Implement token refresh flow |
| Clock skew between servers | Allow 30-60 second leeway in exp check |
Token issued with wrong exp | Check auth server's token lifetime configuration |
| Client clock misconfigured | Sync system time via NTP |
Clock Skew Fix
const jwt = require('jsonwebtoken');
// Allow 30 seconds of clock skew
jwt.verify(token, secret, { clockTolerance: 30 });
Error 2: Invalid Signature
Symptoms
- Server returns 401 with
"invalid signature"or"jwt malformed" - Token was not tampered with, but verification still fails
Diagnosis
This is the most confusing error because the token looks valid when decoded. The payload is readable, but the signature doesn't match.
Common Causes and Fixes
Wrong secret key (HS256):
// Make sure the secret exactly matches what the issuer uses
// Include any whitespace or encoding issues
const secret = process.env.JWT_SECRET; // Verify this matches
jwt.verify(token, secret);
Wrong public key (RS256):
// Fetch the correct public key from the issuer's JWKS endpoint
const jwksClient = require('jwks-rsa');
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json'
});
const key = await client.getSigningKey(kid);
const publicKey = key.getPublicKey();
jwt.verify(token, publicKey);
Algorithm mismatch: The token header specifies alg: "RS256", but the verifier defaults to HS256. Always specify the expected algorithm:
// WRONG — lets the header dictate the algorithm (alg: none attack)
jwt.verify(token, publicKey);
// CORRECT — explicitly specify allowed algorithms
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
Error 3: Audience Mismatch
Symptoms
"jwt audience invalid"or"Audience not allowed"- Token works for one API but not another
Diagnosis
const payload = decodeJWT(token);
console.log('Token audience:', payload.aud);
console.log('Expected audience:', expectedAud);
Common Causes
- The auth server was configured with the wrong audience identifier
- Multiple APIs share tokens but have different audience requirements
- The
audclaim is an array, but the verifier expects a string (or vice versa)
Fix
// Allow multiple audiences
jwt.verify(token, secret, {
audience: ['https://api.example.com', 'https://admin.example.com']
});
Error 4: Issuer Mismatch
Symptoms
"jwt issuer invalid"- Token was issued by a different identity provider than expected
Diagnosis
const payload = decodeJWT(token);
console.log('Token issuer:', payload.iss);
console.log('Expected issuer:', expectedIss);
// Common issue: trailing slash mismatch
// "https://auth.example.com/" !== "https://auth.example.com"
Error 5: Key Rotation Issues
Symptoms
- Tokens suddenly fail after a key rotation event
- Some tokens work and others don't
Diagnosis
When the issuer rotates signing keys, the kid (key ID) in the JWT header identifies which key was used. If your application caches the public key, the cached key may be stale.
Fix
// Use jwks-rsa with caching and rate limiting
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true,
cacheMaxAge: 86400000, // 24 hours
rateLimit: true,
jwksRequestsPerMinute: 10
});
Always extract the kid from the header and look up the corresponding key:
const decoded = jwt.decode(token, { complete: true });
const kid = decoded.header.kid;
const signingKey = await client.getSigningKey(kid);
Debugging Checklist
When a JWT fails, check these in order:
- Structure: Does the token have exactly three dot-separated parts?
- Expiration: Is
expin the past? - Not Before: Is
nbfin the future? - Issuer: Does
issmatch the expected value (exact string, including trailing slashes)? - Audience: Does
audinclude this API's identifier? - Algorithm: Does the
algheader match what the verifier expects? - Signature: Is the correct key being used? Check
kidagainst JWKS. - Clock Skew: Are the client and server clocks synchronized?
Key Takeaways
- Always decode and inspect the JWT payload before debugging authentication code
- Clock skew between servers is the most common cause of unexpected
expfailures - Specify allowed algorithms explicitly during verification to prevent algorithm confusion attacks
- Audience and issuer mismatches often involve subtle string differences (trailing slashes, http vs https)
- Key rotation requires JWKS-based key lookup — never hardcode public keys
- Follow the debugging checklist in order — most JWT errors have straightforward root causes
Try It Yourself
Decode and inspect any JWT instantly with our free JWT Decoder. Paste a token to see its header, payload, and all claims — everything is processed locally in your browser with no server communication.