How to Fix JWT Errors: Invalid Signature, jwt expired, kid Not Found (2026)

Nov 10, 2026 · 14 min · Ajay Kumar

This guide is for backend and platform engineers whose API just started returning 401s with messages like invalid signature, jwt expired, Audience doesn't match or no applicable key found in the JSON Web Key Set. By the end you will be able to decode the failing token safely, map the exact error string to its cause, and run a verifier that pins the algorithm, caches JWKS correctly and tolerates small clock drift. Everything below was tested on 10 November 2026 with jose 6.2.17 on Node 24, PyJWT 2.15.1 on Python 3.12, and jsonwebtoken 9.0.3, using locally generated test keys and a JWKS served from 127.0.0.1. You need one failing token (from a test environment, ideally), the issuer's discovery URL, and curl plus jq. If you want the background on access vs ID tokens and why the browser should not hold them, read the companion explainer, JWT vs sessions and OAuth 2.1 vs OIDC.

Step 1: Decode the token safely, without verifying it

A JWT is three base64url segments: header, payload, signature. The first two are readable by anyone, which is both why debugging is easy and why you should never paste a production token into a website. Decode it locally instead.

# jwtdecode: print header and payload of a JWT without verifying it (bash/zsh, jq 1.7)
jwtdecode() {
  for i in 1 2; do
    printf '%s' "$1" | cut -d. -f"$i" | tr '_-' '/+' \
      | awk '{l=length($0)%4; if(l==2)$0=$0"=="; else if(l==3)$0=$0"="; print}' \
      | base64 -d | jq -c .
  done
}

TOKEN='eyJhbGciOi...'          # strip any "Bearer " prefix first
jwtdecode "$TOKEN"

Real output from a test token:

{"alg":"HS256","kid":"key-1","typ":"JWT"}
{"sub":"u1","iss":"https://issuer.test","aud":"api://orders","exp":1794288325}

Write down four things: alg, kid, iss and aud. Most JWT bugs are visible right here, before you touch a key. Check exp against the clock too:

# Is it expired? Compare exp to now (seconds since epoch)
printf '%s' "$TOKEN" | cut -d. -f2 | tr '_-' '/+' \
  | awk '{l=length($0)%4; if(l==2)$0=$0"=="; else if(l==3)$0=$0"="; print}' \
  | base64 -d | jq '{exp, now: (now|floor), expired: (.exp < now)}'

date -u -r 1794288325        # macOS: human-readable exp
date -u -d @1794288325       # GNU/Linux equivalent

In code, both libraries have an explicit "decode without verifying" call. Use it for logging and routing only, never for authorization:

# PyJWT 2.15.1: inspect only, never trust
import jwt
print(jwt.get_unverified_header(token))   # {'alg': 'RS256', 'kid': 'key-1', 'typ': 'JWT'}
print(jwt.decode(token, options={"verify_signature": False}))
// jose 6.2.17: inspect only, never trust
import { decodeProtectedHeader, decodeJwt } from 'jose';
console.log(decodeProtectedHeader(token)); // { alg: 'RS256', kid: 'key-1' }
console.log(decodeJwt(token));

Step 2: Map the exact error message to a cause

Each library words the same failure differently. These strings are copied from real runs of the three libraries against deliberately broken tokens.

Failure jose 6.2.17 (err.code) PyJWT 2.15.1 jsonwebtoken 9.0.3
Wrong key or tampered token ERR_JWS_SIGNATURE_VERIFICATION_FAILED InvalidSignatureError: Signature verification failed invalid signature
Expired ERR_JWT_EXPIRED ("exp" claim timestamp check failed) ExpiredSignatureError: Signature has expired TokenExpiredError: jwt expired
Wrong audience ERR_JWT_CLAIM_VALIDATION_FAILED, claim=aud InvalidAudienceError: Audience doesn't match jwt audience invalid. expected: ...
Wrong issuer ERR_JWT_CLAIM_VALIDATION_FAILED, claim=iss InvalidIssuerError: Invalid issuer jwt issuer invalid. expected: ...
kid not in JWKS ERR_JWKS_NO_MATCHING_KEY PyJWKClientError: Unable to find a signing key that matches: "key-2" n/a (you supply the key)
alg not in allowlist ERR_JOSE_ALG_NOT_ALLOWED InvalidAlgorithmError: The specified alg value is not allowed invalid algorithm
Not a JWT (e.g. "Bearer " left on) ERR_JWS_INVALID DecodeError: Invalid header padding invalid token (jwt malformed if it is not three segments)

The jose error codes are documented in its errors reference, and PyJWT's exceptions in its API docs. The rest of this guide goes cause by cause.

JWT verification order: find the first check that fails 1. alg allowed? header alg is in your pinned list 2. kid in JWKS? cached set has a key with this kid 3. signature ok? verifies with that public key 4. exp, nbf ok? within clock leeway (30 s is plenty) 5. iss, aud ok? exact string match to your config no no no no no Fix config or reject. Never widen the list to include HS256 next to RS256. Refetch JWKS after cooldown. Check the environment and if a key was rotated early. Wrong key or tenant, token modified, or HS secret encoded wrong (base64 vs raw). Refresh the token. Check NTP on the host. Keep leeway under a minute. ID token sent as an access token, wrong environment, or a trailing slash in iss. All five pass: the token is authentic and current. Only then authorise on scope or roles. Authentication is not permission.
The order most JWT libraries check a token in, and the usual fix at each failure point. Error strings for each check are in the table above.

Cause 1: invalid signature (signature verification failed)

The signature is computed over the exact header and payload bytes, so any change to either, or any mismatch in key, fails it. In practice:

  • Wrong issuer or environment. The token came from staging and you are verifying against production keys, or from tenant A and you hold tenant B's keys. Compare iss from Step 1 with the issuer whose JWKS you loaded.
  • Wrong key from the set. You are hard-coding one PEM instead of selecting by kid. After a rotation the old PEM keeps "working" for old tokens and fails new ones.
  • Something rewrote the token. A proxy URL-decoded it, a log pipeline truncated it, or your code left Bearer or a trailing newline on. Count the dots (exactly two) and the length.
  • HS256 secret encoding. For HMAC, both sides must use the same bytes. If one side treats the secret as a base64 string and the other as raw UTF-8, every signature fails.

Fetch the issuer's real keys and compare kid values:

ISSUER="https://login.example.com"   # your IdP issuer, exactly as in the token's iss
JWKS_URI=$(curl -fsS "$ISSUER/.well-known/openid-configuration" | jq -r .jwks_uri)
curl -fsS "$JWKS_URI" | jq -r '.keys[] | [.kid, .kty, .alg, .use] | @tsv'

If the token's kid is in that list and the signature still fails, you are almost certainly verifying against the wrong key object in code, or the token was modified in transit. For HS256 in jose, pass the secret as bytes explicitly:

// jose: HMAC secret must be bytes. Decide once whether it is raw text or base64.
const secret = new TextEncoder().encode(process.env.JWT_SECRET);       // raw text
// const secret = Buffer.from(process.env.JWT_SECRET_B64, 'base64');  // base64-encoded
await jwtVerify(token, secret, { algorithms: ['HS256'], issuer, audience });

Cause 2: jwt expired, nbf and clock skew

exp is a hard deadline. An expired error is usually correct, and the fix is on the client: refresh the access token before it lapses rather than after a 401. Two cases are not the client's fault:

  • The verifier's clock is wrong. A container on a host without time sync, or a VM restored from a snapshot, can drift by minutes. A token that is "not yet valid (iat)" or "(nbf)" is the tell: the issuer's clock is ahead of yours.
  • Leeway is zero. RFC 7519 allows implementers "some small leeway, usually no more than a few minutes" for exp and nbf. PyJWT defaults leeway to 0, and jose applies no clockTolerance unless you set one. A 30 second tolerance absorbs normal drift without meaningfully extending token life.

Check time sync on the verifying host:

timedatectl status | grep -E 'synchronized|NTP service'   # systemd hosts
chronyc tracking | grep -E 'System time|Leap status'      # chrony hosts
sntp time.apple.com                                       # macOS: prints offset

And set leeway explicitly. In PyJWT this test token expired 10 seconds earlier and still verified with leeway=30; with leeway=0 it raised ExpiredSignatureError: Signature has expired:

jwt.decode(token, key, algorithms=["RS256"], audience=AUD, issuer=ISS, leeway=30)
await jwtVerify(token, JWKS, { algorithms: ['RS256'], audience, issuer, clockTolerance: '30s' });

Resist "fixing" expiry by raising leeway to an hour. That quietly turns a 5 minute token into a 65 minute one.

Cause 3: audience or issuer mismatch

Both checks are exact string comparisons, and both catch real mistakes:

  • An ID token sent to an API. The ID token's aud is the client app's client_id; an access token's aud is your API. If aud in Step 1 looks like a client ID, the frontend is sending the wrong token. Fix the client, not the verifier. Tokens using the RFC 9068 profile also carry typ: at+jwt, and jose can enforce it with the typ: 'at+jwt' option.
  • No audience requested. Some identity providers only issue a JWT access token for your API when the client asks for that API as an audience, resource or scope. Without it, the client may get a token aimed at the provider's own userinfo endpoint.
  • Trailing slashes and environments. https://login.example.com and https://login.example.com/ are different issuers. Copy the value from the discovery document's issuer field rather than typing it.

PyJWT also raises InvalidAudienceError: Invalid audience when the token has an aud but you passed no audience at all, which is the library refusing to guess. Do not silence it with options={"verify_aud": False}; pass the expected value.

Cause 4: kid not found, JWKS caching and key rotation

ERR_JWKS_NO_MATCHING_KEY in jose, or Unable to find a signing key that matches in PyJWT, means the token names a kid your cached key set does not contain. OIDC Core section 10.1.1 describes the expected behaviour: on an unfamiliar kid, go back to the jwks_uri and refetch. Both libraries do this, with a cooldown so junk tokens cannot make you hammer the identity provider:

  • jose createRemoteJWKSet: keys are cached for cacheMaxAge (default 600,000 ms, 10 minutes); an unknown kid triggers a refetch only after cooldownDuration (default 30,000 ms) has passed since the last successful fetch; requests time out after timeoutDuration (default 5,000 ms).
  • PyJWT PyJWKClient: the key set is cached for lifespan (default 300 seconds), and in 2.15.1 an unknown kid triggers one forced refresh subject to cooldown_duration (default 30 seconds). The default HTTP timeout is 30 seconds, which I lower.

That cooldown explains a confusing symptom. In my test, three lookups for an unknown kid within a few seconds produced exactly one HTTP request to the JWKS endpoint and three identical errors. That is correct behaviour, not a stale cache bug. Real causes of a missing kid:

  • The issuer signed with a key before publishing it. Rotation should be publish, wait at least one cache lifetime, then sign. If you run the issuer, fix the order.
  • Wrong environment again. The kid exists in the other tenant's JWKS. Run the curl from Cause 1 against both.
  • No kid at all. PyJWT reports matches: "None". Some issuers omit kid when they have one key; select the single key explicitly or ask the issuer to add it.
  • A cache that never refreshes. Hand-rolled code that loaded the JWKS once at boot will fail on every rotation. Replace it with the library resolver.

Cause 5: RS256 vs HS256 confusion and algorithm not allowed

If verification says alg is not allowed, the token's header names an algorithm you did not list. Usually the fix is in configuration (the issuer signs ES256, you allowed RS256). The wrong fix is to add HS256 to an RS256 list. Mixing symmetric and asymmetric algorithms in one verify call is the precondition for algorithm-confusion attacks, where an attacker signs an HS256 token using your RSA public key as the HMAC secret. That bug class keeps recurring: PyJWT shipped a fix in 2022 (CVE-2022-29217) and needed another in September 2026 (CVE-2026-102268, critical, fixed in 2.14.0). Modern PyJWT refuses the obvious version outright; in my test, jwt.encode(claims, public_pem, algorithm="HS256") raised InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.

Rules that make the whole class go away: one algorithm per key, an explicit algorithms list in every call, and libraries kept current. Check what you are running:

pip show pyjwt | grep Version     # want 2.15.1 (anything <= 2.13.0 has known high/critical advisories)
npm ls jose jsonwebtoken          # jose 6.2.17, jsonwebtoken 9.0.3 at time of writing

Step 3: Use a verifier that gets all five checks right

Both of these were run against a local JWKS server with generated keys: a valid token returned its claims, a token expired two minutes earlier failed with ERR_JWT_EXPIRED, and a token with aud: web-client failed on aud.

// verify.mjs: jose 6.2.17, Node 24
import { createRemoteJWKSet, jwtVerify, errors } from 'jose';

const ISSUER = process.env.OIDC_ISSUER;        // exactly as in the token's iss
const AUDIENCE = process.env.API_AUDIENCE;     // your API identifier, not the client_id
const JWKS = createRemoteJWKSet(new URL(process.env.OIDC_JWKS_URI), {
  cacheMaxAge: 600_000,     // keep keys 10 minutes (default)
  cooldownDuration: 30_000, // unknown kid may refetch at most every 30 s (default)
  timeoutDuration: 5_000,   // fail fast if the IdP is slow (default)
});

export async function verifyAccessToken(authorization) {
  const match = /^Bearer ([\w-]+\.[\w-]+\.[\w-]+)$/.exec(authorization ?? '');
  if (!match) throw new Error('missing or malformed Bearer token');
  try {
    const { payload } = await jwtVerify(match[1], JWKS, {
      issuer: ISSUER,
      audience: AUDIENCE,
      algorithms: ['RS256'],   // pin it; never read alg from the token
      clockTolerance: '30s',   // absorb small clock drift only
      requiredClaims: ['sub'],
    });
    return payload;
  } catch (err) {
    if (err instanceof errors.JWTExpired) console.warn('expired', err.payload?.exp);
    else if (err instanceof errors.JWTClaimValidationFailed) console.warn('claim', err.claim, err.reason);
    else if (err instanceof errors.JWKSNoMatchingKey) console.warn('no key for kid');
    else if (err instanceof errors.JWSSignatureVerificationFailed) console.warn('bad signature');
    throw err;
  }
}
# verify.py: PyJWT 2.15.1 (pip install "PyJWT[crypto]==2.15.1"), Python 3.12
import os
import jwt

ISSUER = os.environ["OIDC_ISSUER"]
AUDIENCE = os.environ["API_AUDIENCE"]
jwks_client = jwt.PyJWKClient(
    os.environ["OIDC_JWKS_URI"],
    cache_jwk_set=True,      # cache the whole set (default)
    lifespan=600,            # seconds before the cached set is refetched (default 300)
    cache_keys=True,         # also LRU-cache resolved keys by kid
    timeout=5,               # default is 30 seconds
)

def verify_access_token(authorization: str) -> dict:
    scheme, _, token = (authorization or "").partition(" ")
    if scheme.lower() != "bearer" or token.count(".") != 2:
        raise jwt.InvalidTokenError("missing or malformed Bearer token")
    signing_key = jwks_client.get_signing_key_from_jwt(token)  # picks by kid
    return jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],          # pin it
        audience=AUDIENCE,
        issuer=ISSUER,
        leeway=30,                     # seconds of clock skew tolerated
        options={"require": ["exp", "iss", "aud", "sub"]},
    )

Log the error class and the decoded iss, aud, kid and exp, never the raw token. A missing sub here raised MissingRequiredClaimError: Token is missing the "sub" claim, which is exactly the kind of message you want in logs. If services in more than one language verify tokens, the same five checks belong in each; a shared gateway, or a per-request call to an introspection endpoint, keeps them from drifting.

Troubleshooting

Symptom Likely cause Fix
invalid signature / Signature verification failed right after a deploy Verifier points at another environment's issuer or JWKS Compare token iss with configured issuer; load JWKS from that issuer's discovery document
Signature fails only for new tokens, old ones pass Hard-coded PEM; issuer rotated keys Select keys by kid with createRemoteJWKSet or PyJWKClient
jwt malformed, invalid token, ERR_JWS_INVALID or Invalid header padding Bearer prefix, quotes or newline left on the token Strip the scheme and whitespace; check there are exactly two dots
DecodeError: Invalid crypto padding on AWS ALB tokens with PyJWT Older PyJWT rejected trailing base64url = padding Upgrade to PyJWT 2.15.1, which accepts it
jwt expired within seconds of issue, or not yet valid (iat) Clock drift on issuer or verifier Fix NTP; set 30 s leeway or clockTolerance
Audience doesn't match / jwt audience invalid ID token sent to the API, or client never requested the API audience Send the access token; request the API's audience or resource at login
Invalid issuer Trailing slash or wrong tenant URL Copy issuer from /.well-known/openid-configuration exactly
ERR_JWKS_NO_MATCHING_KEY that clears after about 30 s Issuer signed with a key before publishing it; cooldown delayed refetch Publish new keys before use; wait one cache lifetime before switching
Unable to find a signing key that matches: "None" Token header has no kid Ask the issuer to set kid, or select the single key explicitly
alg value is not allowed / ERR_JOSE_ALG_NOT_ALLOWED Issuer uses ES256 or PS256, you pinned RS256 Pin the issuer's actual algorithm; never add HS256 to an asymmetric list

FAQ

How do I decode a JWT without verifying it?

Base64url-decode the first two dot-separated segments locally, as in Step 1, or use jwt.get_unverified_header in PyJWT and decodeProtectedHeader or decodeJwt in jose. Use the result only for debugging and key selection. Avoid pasting production tokens into online decoders, because the payload is readable by whoever runs the site.

What clock skew leeway should I allow for JWT exp?

Thirty to sixty seconds is plenty for hosts with working time sync. RFC 7519 says leeway should usually be no more than a few minutes. If you need more than that, fix NTP rather than widening the window.

Why does my JWT say kid not found after key rotation?

Your cached JWKS does not contain the new key yet, or the issuer started signing before publishing it. Library resolvers refetch on an unknown kid but only after a cooldown (30 seconds by default in both jose and PyJWT), so brief errors right after an early rotation are expected.

Should I use RS256 or HS256?

Use an asymmetric algorithm such as RS256, ES256 or EdDSA whenever more than one service verifies tokens, so verifiers cannot mint them. HS256 is acceptable when the same service issues and verifies. Never allow both families in one verification call.

What I check first

When a JWT error lands in my lap, I decode the token locally and read iss, aud, kid and exp before opening any code, because the answer is very often already on screen: wrong environment, ID token sent to an API, or a clock that drifted. Then I check the verifier pins its algorithm and pulls keys by kid from the issuer's discovery document, and that the library version is current. In production I keep access tokens short, leeway at 30 seconds, and alerts on a spike of kid misses, which is usually the first sign someone rotated keys in the wrong order. For deeper reading, the official references are the jose documentation, the PyJWT usage guide, RFC 7519, RFC 8725 on JWT best practices and OIDC Core. For running your own issuer, my Keycloak OIDC setup guide covers realm keys and clients, and the JWT vs sessions explainer covers the token types behind all of this.

Related: How to Set Up Keycloak 26.8 for OIDC and SAML SSO: Step-by-Step (2026), How to Deploy a Secure Remote MCP Server with OAuth (2026) and How to Fix Kubernetes RBAC Forbidden: User Cannot List Resource (2026).

Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related