Why JWT Signature Verification Fails: 8 Causes Worth Checking

·5 min read

jwtoauth2debuggingsecurity

A JWT that decodes cleanly but fails verification is one of the more frustrating things to debug, because the payload looks completely correct. The claims are there, the expiry is in the future, and the token is obviously a valid token — it just isn't valid to your verifier.

Signature verification is binary and it fails for a small number of concrete reasons. Here they are, roughly in the order worth checking.

1. You're verifying with the wrong key

This is the most common cause by a wide margin, and it usually looks like a code bug when it's actually a configuration one.

If the token is signed with RS256, ES256, or any other asymmetric algorithm, the issuer signs with a private key and you verify with the matching public key. Those keys live in the issuer's JWKS endpoint, and issuers typically publish more than one at a time. The token's kid header tells you which:

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "eXaMpLeKeYiD123"
}

If you fetch the JWKS and grab keys[0] instead of matching on kid, you will verify correctly right up until the issuer rotates keys or adds a second one — and then you'll fail intermittently, which is much worse than failing consistently.

Match on kid. Always.

2. Your JWKS cache is stale

Related, and the reason "it worked yesterday" is such a common report.

Issuers rotate signing keys. A well-behaved issuer publishes the new key alongside the old one for a grace period, so tokens signed with either verify during the overlap. If you fetch JWKS once at process start and cache it forever, you will sail through the overlap window and start failing the moment the old key is retired.

Cache JWKS, but respect Cache-Control, and re-fetch on an unknown kid — with a rate limit, so a token carrying a garbage kid can't turn into a request flood against the issuer.

3. You're verifying the wrong bytes

The signing input is the raw encoded header and payload, joined with a dot:

base64url(header) + "." + base64url(payload)

Not the decoded JSON. Not re-serialized JSON. The exact ASCII substring that arrived in the token.

This bites when code decodes a token, inspects it, and then reconstructs the signing input from the parsed object. JSON.stringify will not reproduce the original byte-for-byte — key order may differ, whitespace may differ, and Unicode escaping may differ. Any of those changes the signature.

Take the substring up to the last . from the original token string.

4. Base64url, not base64

JWTs use base64url encoding, which differs from standard base64 in two ways:

standard base64url
index 62 + -
index 63 / _
padding = required stripped

Passing a base64url string to a standard base64 decoder produces either an error or, worse, subtly wrong bytes. Most JWT libraries handle this; hand-rolled decoding frequently does not. If you're seeing failures only on some tokens, this is a strong candidate — a given payload only hits - or _ when its bytes happen to land on those indices.

5. HS256 secret: string vs. bytes

For HMAC algorithms the secret is a byte string, and there are two conventions for getting there: use the UTF-8 bytes of the secret directly, or base64-decode it first.

Both are common. They produce entirely different keys from the same configuration value. If you're integrating two systems that disagree about which one they're using, every signature will fail.

Check what the issuing side does before assuming.

6. The algorithm isn't what you expect

Never trust the alg header to decide how to verify. Pass an explicit allowlist:

jwt.verify(token, key, { algorithms: ["RS256"] });

Without it you're exposed to algorithm confusion. The classic version: an attacker takes an RS256 setup, changes alg to HS256, and signs with the public key as the HMAC secret. A verifier that dispatches on the header will happily validate it, because the public key is, by definition, public.

The related case is alg: "none", which asserts the token is unsigned. Any serious library rejects it by default. Make sure yours does.

7. You're not failing verification at all

Worth ruling out early, because the error messages often blur together.

exp in the past, nbf in the future, or an iss/aud mismatch are all claim validation failures, not signature failures. The signature was fine. Many libraries raise a single generic error for both categories, which sends people hunting for key problems that don't exist.

Read the specific error type before assuming it's the signature. Small clock differences between issuer and verifier are a frequent cause here — most libraries allow a configurable leeway of a minute or two.

8. Something modified the token in transit

Signatures cover the exact bytes, so any transformation invalidates them:

  • URL-encoding or decoding a token that's passed as a query parameter
  • Truncation by a column with a length limit — JWTs get long, and VARCHAR(255) is not enough
  • A proxy or logger that trims whitespace or newlines
  • Copy-paste through something that inserts a line break

A truncated token usually fails to parse rather than fail verification, but a token that lost only trailing padding may parse and then fail the signature check.

Working through it

The fastest path is usually to decode the token and look at the header before touching any code — alg and kid narrow the field immediately. You can do that, and verify against a JWKS endpoint or a shared secret, with our JWT Decoder. It runs entirely in your browser; the token is never sent anywhere.

If the header looks right and verification still fails, work down the list above. In practice it's almost always #1 or #2.

For how AuthAction issues and rotates signing keys, see the OAuth2 endpoint reference, or the backend integration guides for validating tokens in your own API.


Stop debugging signatures by hand

AuthAction issues signed tokens, publishes JWKS, and rotates keys automatically — with SDKs that verify correctly by default.

Free, unlimited users. No credit card required.