Debug JWT Invalid Signature: A Systematic Playbook
Few auth errors waste more engineering time than invalid signature (or library variants like JsonWebTokenError: invalid signature, JWT::VerificationError, or JOSEError). The message is honest but vague: verification ran, and the computed signature did not match the token’s third segment. The cause might be a wrong secret, the wrong public key, a truncated env var, an algorithm mismatch, a kid miss after rotation, or PEM/JWK format confusion.
This playbook walks a fail-closed diagnosis path you can run in minutes. Use the JWT Decoder to read the header safely, the JWT Validator for HS256 labs, and the JWKS Viewer when asymmetric keys are involved. For a shorter checklist, see the topic guide Fix invalid JWT signature. For correct verify defaults, read how to validate a JWT token.
What “Invalid Signature” Actually Means
A compact JWT is header.payload.signature. Verification:
1. Parses the header (alg, optional kid)
2. Selects a key (shared secret or public key)
3. Recomputes the signature over header.payload
4. Compares to the token’s signature segment
If step 4 fails, the library raises invalid signature. It does not mean the token is “expired” (that is usually a separate exp error) and it does not mean Base64URL decode failed (that fails earlier). Treat decode-without-verify as untrusted display only.
Step 0: Reproduce With Explicit Algorithms
Never debug with “accept any algorithm.” Pin the allowlist you expect in production:
// Node.js — jsonwebtoken
const jwt = require('jsonwebtoken');
try {
jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
} catch (err) {
console.error(err.name, err.message);
}# Python — PyJWT
import jwt
import os
try:
jwt.decode(
token,
os.environ["JWT_SECRET"],
algorithms=["HS256"],
)
except jwt.InvalidSignatureError as err:
print("signature", err)
except jwt.PyJWTError as err:
print(type(err).__name__, err)If verification suddenly “works” only after you remove the allowlist, you likely have an algorithm confusion or wrong-key-type problem — not a success to celebrate.
Step 1: Decode the Header (Without Trusting It)
Paste the token into the JWT Decoder and note:
alg— expectedHS256,RS256,ES256, …?kid— present? matches your key store / JWKS?typ— usuallyJWT; rarely the root cause of signature failure
Also decode iat / exp for context, but do not stop at decode. Signature failures are about keys and bytes, not claim timestamps (skew shows up as claim errors more often than signature errors).
Step 2: Branch on Algorithm Family
HS256 / HMAC path
You need the exact same secret bytes the issuer used.
Checklist:
1. Compare decoded byte length after your env encoding (hex vs Base64). See hex vs Base64 secrets.
2. Reject truncated secrets (common when copying from chat or password managers).
3. Confirm no extra quotes/whitespace in .env (JWT_SECRET="abc" vs JWT_SECRET=abc).
4. Confirm you are not verifying with undefined because the env var is missing (boot should fail closed).
5. Generate a known-good secret with the JWT Secret Generator, sign a lab token, and verify in the JWT Validator.
// Detect common env foot-guns
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET missing');
if (secret.length < 64) {
console.warn('HS256 secret looks short if this is hex for 256-bit key (expect 64 hex chars)');
}RS256 / ES256 path
You need the public key that matches the issuer’s private key — usually from JWKS by kid.
Checklist:
1. Confirm alg is in your allowlist (RS256 or ES256).
2. Fetch the configured JWKS URI (not token jku).
3. Paste JWKS into the JWKS Viewer; confirm kid exists and has no private fields.
4. Export public PEM if your library wants PEM instead of JWK.
5. Confirm you are not accidentally verifying RS256 with an HMAC secret (or vice versa).
Operational JWKS guidance: JWKS in production.
Step 3: The Usual Culprits (Ranked)
1. Wrong secret or wrong key
Different environments (dev vs staging vs prod) or an old secret left in one service after rotation. Align env sources and redeploy verifiers together within the dual-key window.
2. Truncated or re-encoded secret
Hex secrets truncated mid-copy still “look long.” Count decoded bytes, not UI string length. Mixing hex loaders with Base64 secrets produces consistent invalid signatures.
3. Algorithm mismatch
Token says RS256 but verifier uses HS256 secret (or the reverse). Always pin algorithms. Attackers historically abused flexible alg handling — see alg:none and algorithm confusion.
4. Unknown or stale `kid`
After rotation, issuers sign with a new kid before verifiers refresh JWKS, or issuers remove the old public key too early. Keep both kids during the access-token TTL window. See how to rotate JWT secrets.
5. PEM / JWK format mistakes
Private PEM passed to a verify API, PKCS#1 vs SPKI confusion, or line-break corruption in Kubernetes secrets. Prefer JWKS for asymmetric verify; when using PEM, stick to SPKI BEGIN PUBLIC KEY for verification.
6. Clock issues mistaken for signatures
Confirm the error name. Expired tokens should not be mislabeled as invalid signature. If only some nodes fail, check NTP — see clock skew.
Step 4: Prove the Key Material Independently
HMAC lab loop
1. Generate a 256-bit secret in the JWT Secret Generator
2. Sign with your issuer code using that exact env value
3. Verify in the JWT Validator
4. If validator passes but your API fails, your API is not using the same bytes
Asymmetric lab loop
1. Build or inspect JWKS in the JWKS Viewer
2. Ensure token kid matches
3. Verify with Node jose / PyJWT against that JWKS
4. If local verify works and gateway fails, the gateway has a stale JWKS cache or different URI
// Node — jose against remote JWKS
const { createRemoteJWKSet, jwtVerify } = require('jose');
const JWKS = createRemoteJWKSet(new URL(process.env.OIDC_JWKS_URI));
await jwtVerify(token, JWKS, { algorithms: ['RS256'] });# Python — PyJWKClient
from jwt import PyJWKClient
import jwt, os
client = PyJWKClient(os.environ["OIDC_JWKS_URI"])
key = client.get_signing_key_from_jwt(token)
jwt.decode(token, key.key, algorithms=["RS256"])Step 5: Logging That Helps (Without Leaking Secrets)
Log:
- Error class / code
- Token
kidandalg(from decode-complete header) - Key source (which env name or JWKS URI host — not the secret)
- Whether JWKS refresh was attempted
Never log:
- Raw HMAC secrets or private PEMs
- Full tokens in long-term storage (prefer truncated
jtiif present)
Decision Tree (Quick)
1. Decode header → note alg + kid
2. If HMAC → compare secret byte length + encoding; lab with validator
3. If RSA/EC → confirm JWKS kid; viewer; refresh cache
4. Pin algorithms; retry
5. If still failing → confirm issuer and verifier use the same key version; check for dual-write bugs during rotation
Prevention Checklist
- [ ] Startup fails if signing/verify keys are missing or undersized
- [ ] Algorithms allowlisted everywhere (no “none”, no silent fallbacks)
- [ ] Secrets managers + unique secrets per environment
- [ ] Dual-
kidrotation rehearsed; JWKS monitored - [ ] CI tests: valid token, wrong secret, wrong alg, expired, alg none
- [ ] Runbooks link decoder, validator, and JWKS viewer
Related Resources
- JWT Validator · JWT Decoder · JWKS Viewer
- Fix invalid signature (quick guide)
- How to validate a JWT
- JWKS in production
- Common JWT vulnerabilities
Frequently Asked Questions
Why does the JWT Decoder show claims if the signature is invalid?
Decode only Base64URL-parses header and payload. It does not prove authenticity or integrity. Always verify with the correct key and an explicit algorithm allowlist before authorizing — treat decoded claims as hostile until verification succeeds.
Can clock skew cause invalid signature?
Usually no. Skew typically surfaces as exp / nbf failures. Invalid signature almost always means key material, algorithm selection, or encoding mismatch. Fix NTP separately if claim errors are intermittent by node, and confirm the exact error class your library raises.
We rotated keys and everything broke — what now?
Re-publish the previous public key (or HMAC secret) under its old kid, keep verifying both kids, and only retire the old key after the max access-token lifetime. Inspect the live JWKS in the JWKS Viewer and compare kids to production tokens before changing issuer code again.
HS256 works in the validator but fails in our API — why?
Your API is almost certainly not using the same secret bytes: different env var, quoting/whitespace, hex vs Base64 decode mismatch, or a staging secret pointed at a prod issuer. Diff the exact secret length after decoding on both sides — do not compare secrets in chat logs or tickets.