JWT Signature Verification Failed: A Debugging Walkthrough
A JWT signature failure gives you almost nothing to go on. Most libraries just throw JsonWebTokenError: invalid signature or return a boolean false — no hint about which of the header, payload, or secret is wrong, because the whole point of a signature is that it can't tell you where it disagrees with you. This walkthrough covers five real causes, how each one actually happens in production code, and a live, prefilled JWT Verifier example for each so you can watch it fail on the exact token instead of a description of one.
Quick reference
All five results below were verified live in JWT Verifier against a real build — HS256 signatures computed with Node's crypto.createHmac and cross-checked against the tool's own Web Crypto HMAC implementation before publishing.
| Symptom | Most likely cause | Fix |
|---|---|---|
| Fails with every secret you try | Token was signed with a different secret than the one you have | Confirm you're pointed at the right secret for the right environment |
| Fails, but the secret "looks right" | Secret stored base64-encoded, used raw instead of decoded (or vice versa) | Match the encoding the signer actually used |
| Old tokens fail, new ones don't | Signing key rotated; verifier only holds the current key | Keep the previous key valid through a grace period, keyed by kid |
| Fails after you "just changed one field" | Payload edited by hand after decoding, signature never recomputed | Re-sign after any claim change — there's no way to patch a signature |
| Fails no matter what you type as the secret | Token is RS256, verifier is checking it as HS256 | Verify with the RSA public key and an RS256 verifier, not a shared secret |
1. The wrong secret entirely
The plain case: the token was signed with one secret, and you're verifying with a different one — often a stale value copied into a .env file, a secret that differs between staging and production, or a typo introduced when it was last rotated by hand.
jwt.verify(token, 'prod-secret-2027');
// JsonWebTokenError: invalid signature
There's no partial credit — a signature comparison is exact, so a secret that's correct except for one character fails exactly the same way as a completely unrelated string. If verification is failing in one environment and not another, diff the actual secret values (not just confirm both are "set") before assuming the bug is anywhere else.
See it fail live
This token was signed with prod-secret-2026. Verifying it against the same secret with a one-character typo:
2. A base64-encoded secret used in the wrong form
Secret managers frequently store signing keys base64-encoded — it's a safe way to hold arbitrary bytes in a text field. But jwt.sign() and jwt.verify() both treat whatever string you pass as the literal HMAC key bytes. If the code that signs a token decodes the base64 first but the code that verifies it doesn't (or the reverse), the two sides are using entirely different key material even though they both point at "the same secret."
// Signing side: decodes the stored value first
const key = Buffer.from(process.env.JWT_SECRET_B64, 'base64');
const token = jwt.sign(payload, key, { algorithm: 'HS256' });
// Verifying side: forgot to decode — uses the base64 TEXT as the key
jwt.verify(token, process.env.JWT_SECRET_B64);
// JsonWebTokenError: invalid signature
Fix by making both sides agree on the encoding — either both decode before use, or store the secret as raw text in the first place and skip the encode/decode step entirely.
See it fail live
Signed with the raw secret super-signing-key-do-not-share. Verifying with its base64 form, unpacked:
3. A rotated signing key, verified with the new secret
Rotating a signing key is good practice, but every token issued under the old key becomes unverifiable the moment the old key is discarded — including tokens that are still inside their exp window and were never meant to be invalidated early. This is the most common way a rotation causes an outage: the rollout swaps the secret everywhere at once, and every session token issued in roughly the last hour starts failing verification simultaneously.
// Token issued a moment ago, signed under kid "v1"
const token = jwt.sign(payload, KEYS['v1'], { algorithm: 'HS256', keyid: 'v1' });
// Verifier only knows about the just-rotated key
jwt.verify(token, KEYS['v2']);
// JsonWebTokenError: invalid signature
The fix is to keep the previous key valid for a grace period and select which key to verify with by reading the token's kid header, instead of hardcoding a single current secret:
const { kid } = jwt.decode(token, { complete: true }).header;
const key = KEYS[kid]; // both v1 and v2 still resolve during the grace window
jwt.verify(token, key);
See it fail live
This token is signed under kid: "v1" with the old secret. First, verify it with rotate-key-v1-abc123 — it's valid. Then try the same token against the new secret:
rotate-key-v1-abc123 to see it pass)
4. The payload was edited after signing
Decoding a JWT and re-encoding it after changing a claim — bumping role from viewer to admin while debugging, say, or hand-patching an exp during a test — silently breaks it, because the original signature covered the exact original bytes. It's easy to miss this while working directly with a decoder: nothing about decoding a token warns you that the signature is now stale, since decoding and verifying are two completely separate operations.
const decoded = jwt.decode(token);
decoded.role = 'admin'; // mutate the claims
const edited = base64url(header) + '.' + base64url(decoded) + '.' + signature;
// still carries the ORIGINAL signature — now stale
jwt.verify(edited, secret);
// JsonWebTokenError: invalid signature
There's no way to patch a signature after editing the payload it covers — the only fix is re-signing the whole token with jwt.sign() once every claim is final. If you're hand-testing role or permission changes locally, regenerate the token instead of surgically editing one.
See it fail live
This token's payload was changed from role: "viewer" to role: "admin" after signing, keeping the original signature untouched:
5. Verifying an RS256 token as HS256
Identity providers like Auth0, Okta, and Firebase issue RS256 tokens by default — signed with an RSA private key, meant to be verified with the matching public key. HS256 is a completely different algorithm family: a shared secret and HMAC, not asymmetric key pairs. Paste an RS256 token into an HS256-only verifier and no string you type as the "secret" will ever produce a matching signature, because the two algorithms compute fundamentally different bytes from the same input.
jwt.verify(rs256TokenFromAuth0, 'any-string-here', { algorithms: ['HS256'] });
// JsonWebTokenError: invalid signature — always, regardless of the string
The signal that you're in this case rather than one of the four above: decode the header first. If alg reads RS256, RS384, ES256, or anything other than an HSxxx value, no shared secret exists to type in at all — you need the issuer's public key (or JWKS endpoint) and a verifier built for that algorithm family.
alg the token itself declares instead of pinning it to what your issuer actually uses, an attacker who knows your RSA public key — which is, by definition, public — can craft a token with alg: "HS256" and sign it using that public key as an HMAC secret. A verifier that blindly follows the token's own alg header will accept it. Always hardcode the expected algorithm on the verifying side ({ algorithms: ['RS256'] }, never inferred from the token) rather than trusting what the token claims about itself.
See it fail live
A real RS256-signed token (2048-bit key, generated for this example). No secret you enter here will ever make it valid — that's expected, and it's the point:
Open this example in JWT VerifierDebugging checklist
- Decode the header first (no verification needed) and confirm
algmatches what you expect to verify with. An RS256 token in an HS256 verifier will fail no matter what else you check. - Check the
kidclaim, if present, against your current key set — a mismatch usually means the signing key has rotated since this token was issued. - Confirm the secret's encoding matches on both the signing and verifying side — raw text and its base64 form are different key material even though they represent "the same" secret.
- If the payload was ever decoded and hand-edited for testing, re-sign it — a stale signature over new claims will never verify.
- Diff the actual secret value between environments rather than trusting that both sides have "something set" for it.
🔐 Paste your token and see exactly where it fails
JWT Verifier decodes the header and claims, checks exp/nbf, and verifies HS256 signatures entirely in your browser using the Web Crypto API — nothing is uploaded, and the secret field is never stored.