Skip to content
ohsudo, Home
Security

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.

Intermediate5 min readApplies to JSON Web Tokens · Node.js jsonwebtoken · PyJWT · OAuth 2.0 · OpenID Connect

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 expired
JsonWebTokenError: invalid signature
JsonWebTokenError: jwt malformed
NotBeforeError: jwt not active

Those 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.

  1. Issuersigns header + payload
  2. The tokenheader . payload . signature
  3. Your APIchecks signature, exp, nbf(breaks here)
A JWT is three readable parts. The server checks the signature with its key, then the times in the payload; each error names the check that failed.

TL;DR

  • jwt expired: exp in 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, alg and kid.
  • jwt malformed: it isn’t a JWT at all. Often Bearer was 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:

Terminal window
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 exp only a minute after iat. Often a unit mix-up: expiresIn: 3600 means seconds, and expiresIn: "3600", a string, means milliseconds in jsonwebtoken.
  • exp in milliseconds. The standard wants seconds. Written with Date.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 Secret created with echo instead of echo -n.
  • A secret stored as Base64, used by one side decoded, and by the other as plain text.
  • A different algorithm. alg in the header says RS256, and you verify with a shared secret, or the reverse. RS256 signs with a private key and verifies with the public one.
  • A rotated key. With an identity provider, kid in 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 the Bearer prefix before verifying.
  • undefined, null or an empty string: the client didn’t send it, or a proxy dropped the Authorization header.
  • 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.

Tools

Try it yourself

Articles

Related articles