JSON Web Tokens (JWT, standardized in RFC 7519) represent the backbone of modern stateless authentication in microservices, OAuth 2.0 / OpenID Connect (OIDC) identity flows, and single-page applications.
However, because JWTs are compact, Base64Url-encoded strings with cryptographic signatures, debugging authorization errors like JsonWebTokenError: invalid signature, TokenExpiredError: jwt expired, or JWKS lookup failed can be notoriously frustrating.
This troubleshooting manual breaks down the cryptographic structure of JWTs, walks through the most common verification failures, and explains how to resolve key rotation and token validation bugs.
1. Deconstructing the JWT Structure
A JSON Web Token consists of three parts separated by periods (.):
HEADER.PAYLOAD.SIGNATURE
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjIwMjYtMDktMDQifQ.eyJzdWIiOiJ1c3JfMTIzNDU2IiwibmFtZSI6IkphbmUgRG9lIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzk4NjkwODAwLCJpYXQiOjE3OTg2ODcyMDB9.i7v1-t7oX1zW4dF_example_signature...
1. Header (Metadata & Algorithm)
Contains the signature algorithm (alg, e.g., HS256, RS256, ES256), token type (typ), and optional Key ID (kid):
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-2026-auth"
}
2. Payload (Claims)
Contains standard registered claims and custom application metadata:
sub(Subject): The user or client identifier.exp(Expiration Time): Unix timestamp (in seconds) after which the token is rejected.nbf(Not Before): Token cannot be accepted prior to this timestamp.iat(Issued At): When the token was generated.iss(Issuer) /aud(Audience): Verification domains.
3. Signature (Cryptographic Integrity)
Computed over the Base64Url-encoded Header and Payload:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secretOrPrivateKey
)
2. Top 5 JWT Verification Errors & Root Cause Fixes
1. JsonWebTokenError: invalid signature
- Root Cause A — Secret Mismatch (Symmetric
HS256): The verification service uses a different secret string than the signing service. Check for trailing spaces or newline characters in your.envvariables (JWT_SECRET="secret\n"vs"secret"). - Root Cause B — Public vs Private Key Inversion (Asymmetric
RS256): The auth server signs the token with the Private Key (.pemformat). The API gateway or consuming microservice MUST verify it using the matching Public Key or JWKS certificate. Verifying with the private key or wrong public certificate fails immediately. - Root Cause C — Whitespace & Encoding Drift: Any alteration of character casing, missing period delimiters, or premature URL-decoding before passing the token to the validator causes signature validation to fail.
2. TokenExpiredError: jwt expired & Clock Skew
- The Issue: The token's
expclaim is in the past compared to the server's current clock (Math.floor(Date.now() / 1000)). - Clock Skew: Distributed servers rarely have clocks synchronized to the exact millisecond. If the auth server issues a token with timestamp $T$, and the backend receiver's clock is 3 seconds ahead, verification fails instantly.
- The Fix: Configure a leeway / clock tolerance (e.g. 10–30 seconds) in your JWT verification library:
import jwt from 'jsonwebtoken';
// Verify with 30 seconds of clock tolerance:
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
clockTolerance: 30, // in seconds
});
3. The alg: "none" Algorithm Confusion Attack
- Vulnerability: RFC 7519 historically supported an unsigned token algorithm
alg: "none". Attackers could alter the payload (e.g. change"role": "user"to"role": "admin"), setalg: "none", remove the signature portion, and bypass naive verification logic. - The Fix: Never allow dynamic algorithm selection from the token header. Explicitly enforce the allowed algorithms array during verification:
// ❌ Dangerous: Accepts any algorithm declared in the JWT header
jwt.verify(token, secret);
// ✅ Secure: Strictly enforces expected asymmetric algorithm
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
});
4. JWKS (kid) Lookup Failures & Key Rotation
- The Issue: When using Auth0, Supabase, Cognito, or Keycloak, the verification service dynamically fetches JSON Web Key Sets from
/.well-known/jwks.json. If the identity provider rotates signing keys and publishes a newkid, downstream services with stale in-memory caches will fail to resolve the signing key, throwingSigningKeyNotFoundError. - The Fix: Use a resilient JWKS client with cache TTL, rate limiting, and on-demand cache refreshing on unknown
kid:
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 600000, // 10 minutes
rateLimit: true,
jwksRequestsPerMinute: 10,
});
async function getKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) {
if (!header.kid) {
return callback(new Error('Missing kid in token header'));
}
const key = await client.getSigningKey(header.kid);
const signingKey = key.getPublicKey();
callback(null, signingKey);
}
5. Audience (aud) and Issuer (iss) Mismatches
- The Issue: When multiple microservices share an auth provider, tokens must be scoped to specific audiences. If a token issued for
aud: "mobile-api"is sent to thebilling-api, verification must reject it. - The Fix: Always specify and validate
issuerandaudience:
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com/',
audience: 'https://api.example.com/billing',
});
3. Practical Security Best Practices Checklist
- Keep Access Tokens Short-Lived: 5 to 15 minutes max lifetime. Rely on HTTP-only refresh tokens or session rotation for renewals.
- Never Store Sensitive Secrets in Claims: JWT payloads are Base64Url-encoded, not encrypted. Anyone in possession of the token can read passwords, PII, or API secrets placed in the payload. Use JWE (JSON Web Encryption) if payload confidentiality is required.
- Validate Expiration Timestamps in Seconds (Not Milliseconds): The
expclaim is defined in Unix seconds. StoringDate.now()(milliseconds) produces tokens valid for hundreds of years.
Frequently Asked Questions
Can I decode a JWT on the client side without verifying the signature?
Yes. Because the header and payload are simple Base64Url-encoded JSON objects, you can inspect token claims (like user ID or expiration) purely client-side without the private key. Use the JWT Decoder Tool to inspect claims safely in your browser.
What is the difference between symmetric (HS256) and asymmetric (RS256) signing?
- HS256 (HMAC with SHA-256): Uses a single shared secret for both signing and verifying. Both the auth server and every API consumer must know the same secret.
- RS256 (RSA Signature with SHA-256): Uses a private key to sign and a public key/JWKS certificate to verify. Only the auth server knows the private key; all public consumers can verify tokens safely without compromising security.