JWT "jwt expired" and "invalid signature": how to find out what is wrong with a token
Fix JWT "jwt expired" and "invalid signature": read the exp claim, check clock differences, the secret, HS256 vs RS256 and rotated keys, without turning verification off.
by OhSudo ··
Checked on real software: jsonwebtoken, PyJWT · November 2026How we check
Your API starts answering 401, and the log says one of these:
TokenExpiredError: jwt expiredJsonWebTokenError: invalid signatureJsonWebTokenError: jwt malformedNotBeforeError: jwt not activeThose are from Node’s jsonwebtoken. Python’s PyJWT says Signature has expired and Signature verification failed, and other libraries are similar. They tell you which of a JWT’s checks failed. The first step is always the same: look inside the token.
A JWT isn’t encrypted. It’s three Base64URL parts separated by dots: a header, a payload and a signature. Anyone can read the first two, and only the signature proves they weren’t changed.
- Issuersigns header + payloadcreates
- The tokenheader . payload . signatureinvalid signature, or jwt expired(fails)
- Your APIchecks signature, exp, nbf(breaks here)
TL;DR
jwt expired:expin the payload is in the past. Get a new token, and use refresh tokens. Check the clocks if tokens expire “too early”.invalid signature: the key that verifies isn’t the one that signed. Compare the secret,algandkid.jwt malformed: it isn’t a JWT at all. OftenBearerwas left in, or the header was empty.- Never fix any of these by turning verification off.
1. Read the token
Paste it into the JWT Decoder, which runs in your browser and shows the header and claims, with exp and iat as dates. It doesn’t send the token anywhere. Even so, treat a production token as a password, and prefer a test one.
Or from a terminal:
TOKEN='eyJhbGciOi…'echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo{"sub":"42","iat":1793836800,"exp":1793840400,"iss":"https://auth.example.com","aud":"api"}exp and iat are Unix timestamps in seconds. To read them, use the Timestamp Converter, or date -u -d @1793840400.
2. “jwt expired”
Compare exp with the time now. If it has passed, the library is right. What to do depends on the cause:
- A normal expiry. Access tokens are short-lived on purpose. The client should request a new one with its refresh token, and retry. If users get logged out in the middle of working, the refresh flow is what’s missing.
- A very short lifetime, like
exponly a minute afteriat. Often a unit mix-up:expiresIn: 3600means seconds, andexpiresIn: "3600", a string, means milliseconds injsonwebtoken. expin milliseconds. The standard wants seconds. Written withDate.now(), it lands thousands of years ahead, and without a check, the opposite mix-up makes tokens look long expired.- A token cached too long, by a client, a CI job or a script.
Clocks. If a token fails right after it was issued, the server checking it may disagree about the time. Compare date -u on both machines, and keep them synchronized with NTP. A few seconds of tolerance are normal:
jwt.verify(token, key, { clockTolerance: 30 });jwt.decode(token, key, algorithms=["RS256"], leeway=30)That covers small differences. Don’t use it to hide a clock that’s minutes off: fix the clock. jwt not active is the same problem from the other side, with nbf or iat in the future.
3. “invalid signature”
The token was signed with a different key from the one checking it. Go through these:
- The secret differs between services or environments. Staging tokens sent to production, or one service with an old value. Compare hashes rather than the secrets themselves, with the Base64 and hash tool or
printf '%s' "$JWT_SECRET" | sha256sum. - A stray space or newline in the secret, from an environment file or a
Secretcreated withechoinstead ofecho -n. - A secret stored as Base64, used by one side decoded, and by the other as plain text.
- A different algorithm.
algin the header saysRS256, and you verify with a shared secret, or the reverse.RS256signs with a private key and verifies with the public one. - A rotated key. With an identity provider,
kidin the header says which key signed. Your service may hold an old copy: fetch the current keys from the provider’s JWKS address, and refresh them regularly. - A changed token. Copied incompletely, with its end cut off, or re-encoded on the way.
Warning
Don’t “fix” it by skipping the check. jwt.decode() reads a token without verifying it, and options like verify_signature: False turn the check off. Then anyone can write any sub they like, and be anyone. Also pin the algorithm you expect: algorithms: ['RS256']. Accepting whatever the token’s own header says has caused real security holes.
4. “jwt malformed” and the header
The value isn’t three dot-separated parts. Log its length and first characters, never the whole token:
Bearer eyJ…passed as is: remove theBearerprefix before verifying.undefined,nullor an empty string: the client didn’t send it, or a proxy dropped theAuthorizationheader.- Quotes around it, from
JSON.stringify, or from how it was stored.
5. Other claims that fail
| Message | Claim | Means |
|---|---|---|
jwt audience invalid |
aud |
The token was issued for another API |
jwt issuer invalid |
iss |
Another identity provider or environment |
invalid algorithm |
alg |
Not in your allowed list, which is the check working |
6. Keys in CI/CD and containers
If it fails only after a deploy, the new environment probably got a different secret. Check how it’s delivered, and keep it out of images and manifests: use your platform’s secrets, as in the CI/CD Pipeline Generator. For a new HS256 secret, take at least 32 random bytes: openssl rand -base64 32, or a token from the Password Generator.
Shell, we have a problem!
Similar, but different: SSL certificate problem when fetching the keys is about TLS: see unable to get local issuer certificate. A 403 with a valid token is usually about permissions, not the token: read the roles or scopes in its payload.
Found this useful? Share it.
Found a mistake? Let us know at contact@ohsudo.com.