DEV Community

Dan Lumatic
Dan Lumatic

Posted on AI-assisted

JWT verification failing? The error map that names the cause

TL;DR

  • Signature verification failed is one message for several causes: a different key (a one-byte difference is enough), a tampered payload, or the wrong algorithm family.
  • Expired, not yet valid, audience, issuer and missing-claim failures each raise their own exception class, so the class tells you which claim to check.
  • jwt.decode(token, options={"verify_signature": False}) returns a tampered {'role': 'admin', ...} payload as if nothing happened. Keep it out of request paths.

Your login works on your laptop. In prod every request dies with InvalidSignatureError: Signature verification failed, and you start doubting the signature. We got that exact text from a token signed with a different key, and again from a token whose payload we edited by hand. The message can't tell you which one you have. The exception class and the claim it names can, and this post maps each PyJWT error to its cause.

Find your error string here, then jump to the fix

  • Signature has expired (ExpiredSignatureError): exp is in the past.
  • The token is not yet valid (nbf) or (iat) (ImmatureSignatureError): nbf or iat is in the future.
  • Signature verification failed (InvalidSignatureError): different key bytes, tampered payload or wrong algorithm family.
  • The specified alg value is not allowed (InvalidAlgorithmError): the token's alg is not in the list you pinned.
  • Audience doesn't match (InvalidAudienceError) or Invalid issuer (InvalidIssuerError): aud or iss differs from what your service expects.
  • Token is missing the "exp" claim (MissingRequiredClaimError): you required exp and the token has none.
  • No error, but wrong data: someone called decode with verify_signature off.

Read the header first, it needs no key

A JWT is three dot-separated base64url parts, and the first one names the algorithm. jwt.get_unverified_header() reads it without any key:

import jwt

print(jwt.get_unverified_header(token))
# for an HS256 token the header is {"alg":"HS256","typ":"JWT"}
Enter fullscreen mode Exit fullscreen mode

Try this: before you touch any code, print the header of the token that fails and read alg. Everything below that involves a key or an algorithm starts there.

Signature has expired is the honest error

ExpiredSignatureError means what it says: exp is in the past. In prod it usually points at clocks or long-running jobs.

PyJWT's leeway option absorbs small clock differences between machines. With leeway=120, a token that expired 60 seconds earlier passed:

jwt.decode(token, key, algorithms=["HS256"], leeway=120)
Enter fullscreen mode Exit fullscreen mode

This is standard behavior. RFC 7519 says implementers "MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew".

If a fresh token "expires immediately" on one machine only, compare that machine's clock before you touch the token.

The token is not yet valid means nbf or iat

ImmatureSignatureError covers two claims. A future nbf produces The token is not yet valid (nbf). A future iat produces the same class with (iat) at the end.

When the issuing and verifying machines disagree about the time, a token can arrive "from the future". Leeway absorbs this case too: with leeway=120, tokens with nbf or iat 60 seconds in the future passed.

Read the last word in parentheses, then check both claims on the failing token.

Signature verification failed is a family

InvalidSignatureError fires when the signature check fails, and three different mistakes fail it the same way:

  1. The verifier loads different key bytes than the signer used.
  2. Someone changed the payload and kept the old signature. We re-encoded the claims and left the signature alone.
  3. The algorithm family doesn't match.

Cases 1 and 2 returned the same message, character for character. We hit cause 1 three ways with keys that look identical in a config file.

A brand-new token with the wrong key fails the same way. We signed a token with one 32-byte key and verified it seconds later with another: InvalidSignatureError: Signature verification failed. The same token decoded fine with the key that signed it. When a freshly minted token fails immediately, the error points at key bytes, not clocks.

A one-byte difference in the key is enough. The common version: the signer writes the secret to a file, the verifier reads it with open("jwt.secret", "rb").read() and picks up a trailing newline. In our run, the 33-byte key with \n at the end failed, and the same content with .strip() decoded. The keys look identical in any log line; repr() and a hash make the difference visible:

import hashlib

key = open("jwt.secret", "rb").read()
print(repr(key), len(key))                     # b'wN1e...g0C\n' 33
print(hashlib.sha256(key).hexdigest()[:16])    # e05e373e1b07f4ad
Enter fullscreen mode Exit fullscreen mode

Run them on the machine that signs and the machine that verifies. Same length and hash prefix: same key bytes. Different output: you found the bug.

A base64 secret used without decoding fails too. We stored a 32-byte key as base64 text, the 44-character form you paste into an env var, and verified with that raw text: InvalidSignatureError. After base64.b64decode(), the same token decoded. Decode a base64-stored secret once, where you load it.

Pinning the algorithm list also turns attacks into clear refusals: an alg: none token checked against algorithms=["HS256"] raises InvalidAlgorithmError, a different class whose message names the problem.

When you see InvalidSignatureError, compare key hashes first; when you see InvalidAlgorithmError, the pinned list refused the token's alg.

Audience doesn't match and Invalid issuer compare the token to your config

InvalidAudienceError and InvalidIssuerError mean the claims don't match what your service expects. You pass the expected values as arguments: jwt.decode(token, key, algorithms=["HS256"], audience="api://billing", issuer="https://auth.example.com"). RFC 7519 section 4.1.3 says a principal processing the token "MUST identify itself with a value in the audience claim".

Typical cause in a multi-service setup: a token minted for service A is presented to service B.

Print the token's aud and iss (debugging only) and put them next to the values your verifier is configured with.

Decode is not verify

jwt.decode() with verify_signature set to False skips the signature check and returns whatever the payload says. On the tampered token from above:

claims = jwt.decode(token, options={"verify_signature": False})
# {'role': 'admin', ...}
Enter fullscreen mode Exit fullscreen mode

Reading claims while you debug is fine. Anything that grants access based on that dictionary is a breach: an attacker wrote the payload.

Search your codebase for verify_signature today and remove every hit that sits in a request path.

Pin these in every decode call

  • algorithms=["HS256"] (or your algorithm), so alg switching fails loudly.
  • options={"require": ["exp"]}. Without exp a token never expires; requiring it raises MissingRequiredClaimError: Token is missing the "exp" claim.
  • A small leeway.
  • An HMAC key of at least 32 bytes. PyJWT 2.10+ warns on shorter ones: InsecureKeyLengthWarning: The HMAC key is 9 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.

FAQ

Is base64 part of the security? No. The parts are encoded, not encrypted: the header reads with no key, and the payload came back with signature checking off. The signature is what exposes changes.

What leeway should I use? The RFC says "usually no more than a few minutes". The examples above used 120 seconds.

Why does the token work in dev and fail in prod? Four things that differ between machines, each mapped above: the key bytes the verifier loads (a trailing newline, or base64 nobody decoded), the system clock, and the expected aud and iss.

Does a valid signature mean I should trust the claims? No. exp, nbf, iat, aud and iss are separate checks, each with its own exception class.

Log the exception class, not the message

Catch each class in its own handler; each already knows where to look:

Try this: replace your single except Exception with separate handlers and log type(e).__name__.

try:
    claims = jwt.decode(token, key, algorithms=["HS256"],
                        options={"require": ["exp"]}, leeway=120)
except jwt.ExpiredSignatureError:    # exp: compare clocks
    ...
except jwt.ImmatureSignatureError:   # nbf or iat: compare clocks, read the tail
    ...
except jwt.InvalidSignatureError:    # key bytes, payload or alg family: compare hashes
    ...
except jwt.InvalidAudienceError:     # aud: compare with your config
    ...
except jwt.InvalidIssuerError:       # iss: compare with your config
    ...
except jwt.MissingRequiredClaimError:  # exp absent: fix the issuer
    ...
Enter fullscreen mode Exit fullscreen mode

Once the logs carry class names, most JWT bugs turn into one-line fixes.

Sourcing: every message and behavior above was captured by running PyJWT 2.15.1 on Python 3.11, including the key-loading cases above. Claim rules come from RFC 7519, and option names from the PyJWT docs.

Top comments (1)

Collapse
 
konanturan profile image
Turan Konan •

Useful map. Two more worth adding, mostly for RS256 setups:

kid mismatch after key rotation. The issuer starts signing with a new key, your verifier still has the old JWKS cached, and you get InvalidSignatureError even though nothing is "wrong" with the token. jwt.get_unverified_header(token)["kid"] tells you which key it wants. PyJWKClient refetches the set when it sees an unknown kid, a hand-rolled cache usually doesn't.

base64 vs base64url secrets. Some providers hand out the HMAC secret as base64url (- and _ instead of + and /, no padding). base64.b64decode either fails or quietly decodes different bytes. base64.urlsafe_b64decode with the padding added back fixes it. Same family as your trailing newline case: print the sha256 of the key bytes on both sides and compare.