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.