A JSON Web Token is three Base64url pieces joined by dots, and almost every error message about one comes down to a handful of causes: the wrong clock, the wrong key, the wrong audience or a token that was mangled on the way. This guide walks through each and shows how to find which one you have.
Read the token before you blame the library
A JWT looks like xxxxx.yyyyy.zzzzz. The first part is the header, the second the payload (the claims) and the third the signature. The header and payload are plain JSON encoded with Base64url, so anyone holding the token can read them; a JWT is signed, not encrypted.
Decode the first two parts and look at them before changing any code. The JWT decoder does this in your browser without sending the token anywhere. You are looking for:
algin the header: which signing algorithm the issuer used.kidin the header: which key was used, when the issuer rotates keys.exp,nbfandiatin the payload: when the token expires, becomes valid and was issued.issandaud: who issued the token and who it is meant for.
Decoding is not verifying. A decoded payload tells you what the token claims, not whether the claims are genuine; only a signature check against the right key proves that. Never trust a claim you have not verified.
"jwt malformed" and "invalid token"
A JWT must have exactly three dot-separated segments (or five for an encrypted JWE). The most common reasons a library reports a malformed token are mundane:
- The value still starts with
Bearer. TheAuthorizationheader carriesBearer <token>, but the library wants only the token. - A trailing newline, quote or space was copied along with it, for example from a shell variable or a
.envfile. - The token was cut off by a cookie size limit, a log line limit or a URL length limit, so the signature is missing or truncated.
- A proxy or form encoded it:
+,/or=characters appear where Base64url should only use-and_. - You passed a refresh token, an opaque access token or a session ID that is not a JWT at all.
Print the token’s length and the number of dots, then try decoding the first segment. If it does not decode to JSON that starts with {"alg", the problem is upstream of the verifier. The Base64 guide explains why a token that contains + or / was altered.
"jwt expired" and clock problems
The exp, nbf and iat claims are NumericDate values: whole or fractional seconds since 1970-01-01T00:00:00Z. JavaScript’s Date.now() returns milliseconds, so two mistakes are common: an exp built from milliseconds lands tens of thousands of years in the future, and an exp in seconds compared directly with Date.now() always looks expired. Paste the claim into the Unix timestamp converter to see the real date and the unit it detected.
Other clock causes:
- Skew between servers. Issuer and verifier clocks can differ by seconds. Most libraries accept a small leeway, commonly 30 to 60 seconds, for
expandnbf. A token that fails with “not before” right after login usually means the verifier’s clock is behind the issuer’s. - A container or VM with a stale clock. Check the time on the failing machine, not on your laptop.
- Lifetimes that are too short. A five-minute access token plus a slow request can expire in flight. Refresh before it expires, not after the first 401.
- Caching a token past its lifetime. Store
expwith the token and renew early.
For expired tokens the fix is almost never to disable the expiry check. Issue a new token, or fix the clock.
"invalid signature"
The signature covers the exact header and payload bytes, so this error means the verifier computed something different from what the issuer signed. Work through the causes in order:
- Wrong key. An HMAC algorithm (
HS256) needs the same shared secret on both sides, including identical encoding: a Base64-encoded secret that one side decodes and the other uses literally gives a different key. RSA and ECDSA algorithms (RS256,ES256) sign with a private key and verify with the matching public key. - Key rotation. If the header has a
kid, fetch the key with that ID from the issuer’s JWKS endpoint. A cached key set that predates a rotation fails every new token until it is refreshed. - Wrong environment. Staging and production usually use different keys, and a token from one fails in the other.
- Altered token. Any change to the first two segments, even re-encoding the same JSON with different spacing, breaks the signature. Do not decode, edit and re-encode a token and expect it to verify.
- Algorithm mismatch. The verifier must be told which algorithms to accept, and it must not take that choice from the token’s own header.
Step five matters for security as well as debugging. Libraries that trust the header have allowed attackers to switch an RS256 token to HS256 and sign it with the public key as the secret, or to claim "alg": "none" and skip the signature entirely. Always configure an explicit allow-list such as ["RS256"].
Audience, issuer and scope errors
Signature and expiry can both be fine while the token is still rejected, because it was issued for someone else:
audmismatch. The audience identifies the API the token is for. An access token minted for one API is correctly refused by another. The claim may be a string or an array, so compare with that in mind.issmismatch. The issuer must match exactly, including scheme and trailing slash:https://auth.example.comandhttps://auth.example.com/are different values.- Missing scope or role. The token is valid but its
scope,scporrolesclaim lacks the permission the endpoint wants. That is a 403, not a 401, and the fix is on the authorisation side. - ID token used as an access token. An ID token is for the client application to read; send the access token to your API.
Decode the token and compare iss and aud with your verifier configuration character by character. These errors are configuration errors, so no amount of retrying will fix them.
A minimal debugging script
This function decodes a token without verifying it, handling the Base64url alphabet and missing padding. Use it only to inspect, never to authorise:
function decodeJwt(token) {
const [h, p] = token.replace(/^Bearer\s+/i, '').trim().split('.');
const part = (s) => {
const b64 = s.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(s.length / 4) * 4, '=');
return JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))));
};
const payload = part(p);
return { header: part(h), payload, expiresAt: payload.exp && new Date(payload.exp * 1000).toISOString() };
}
The TextDecoder step matters: atob returns Latin-1 text, so a name with an accent or an emoji would otherwise come out garbled. When the script throws, the message tells you which of the earlier problems you have.
Be careful where you paste real tokens. A live access token is a credential until it expires, so use a decoder that runs locally and does not upload it, as described in the guide on online formatters and secret leaks.
Frequently asked questions
Can I decode a JWT without the secret?
Yes. The header and payload are only Base64url-encoded JSON, so anyone can read them. You need the key only to verify the signature, which is what proves the token is genuine.
Why does my JWT say expired when it was just issued?
Usually a unit or clock mistake. The exp claim is in seconds, so a value computed in milliseconds is wrong, and a verifier clock that is ahead of the issuer makes a fresh token look old.
What does "invalid signature" mean?
The signature the verifier computed differs from the one in the token. The usual causes are the wrong secret or public key, a key that has been rotated, a different environment or a token that was altered.
Is it safe to paste a JWT into an online decoder?
Only if the tool runs entirely in your browser. A live token works as a password until it expires, so use a client-side decoder, or decode it yourself, and never share production tokens in chats or tickets.
How much clock skew should I allow?
Thirty to sixty seconds of leeway on exp and nbf is common. Keep it small, since a large leeway lengthens the time a stolen token stays usable.