JWT Signature Verification Failed: A Debugging Walkthrough

Published 3 Sep 2026 · 8 min read

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 answer: verification recomputes the signature from the token's header and payload bytes using your secret, then compares it to the signature segment already on the token. Any difference in those inputs — the secret itself, its encoding, which key was current when the token was issued, whether the payload was edited after signing, or which algorithm produced the signature — makes the comparison fail, with no more detail than that.

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.

SymptomMost likely causeFix
Fails with every secret you tryToken was signed with a different secret than the one you haveConfirm 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'tSigning key rotated; verifier only holds the current keyKeep 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 recomputedRe-sign after any claim change — there's no way to patch a signature
Fails no matter what you type as the secretToken is RS256, verifier is checking it as HS256Verify 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:

Open this example in JWT Verifier

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:

Open this example in JWT Verifier

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:

Open this example in JWT Verifier (swap the secret field to 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:

Open this example in JWT Verifier

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.

Security note: this is a real vulnerability class, not just a debugging dead end. If your server-side verification trusts the 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 Verifier

Debugging checklist

  1. Decode the header first (no verification needed) and confirm alg matches what you expect to verify with. An RS256 token in an HS256 verifier will fail no matter what else you check.
  2. Check the kid claim, if present, against your current key set — a mismatch usually means the signing key has rotated since this token was issued.
  3. 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.
  4. If the payload was ever decoded and hand-edited for testing, re-sign it — a stale signature over new claims will never verify.
  5. 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.

Open JWT Verifier →

← Back to Blog · Next: JWT Not Active (nbf Claim Error) →