First, decode the token and read it
Before changing any code, decode the token and look at what's actually inside. Nine times out of ten the error message tells you the category of problem, and the decoded header and payload tell you the specific cause. Paste the token into the decoder and check three things straight away: the alg in the header, the exp claim in the payload, and the iss and aud claims. Most of the errors below are visible at a glance once you can read the token.
Remember that decoding is not verifying — anyone can read a JWT. So decoding never fails for security reasons; it only fails if the token is structurally broken. If your library throws on verification, the token itself is usually fine and the problem is a mismatch between how it was signed and how you're checking it.
"Invalid signature" / "signature verification failed"
This is the most common JWT error. It means the signature on the token does not match the signature your server computed from the header and payload. The token is not necessarily forged — far more often, your verifier is using the wrong key or the wrong algorithm. Work through these causes in order:
- Wrong secret or key. For
HS256, the exact same secret string must be used to sign and to verify. A trailing newline, a different environment variable, or a dev-vs-prod secret mismatch will all fail. ForRS256/ES256, make sure you're verifying with the public key that pairs with the private key that signed the token. - Key encoding. A secret passed as a UTF-8 string in one place and Base64-decoded in another produces different bytes. Confirm both sides treat the key the same way.
- The token was re-signed or rotated. If the issuer rotated its keys, an old token may have been signed with a key your verifier no longer has. Check the
kidheader and your JWKS. - Whitespace or truncation. A token copied out of a log or header can pick up a leading space or get cut off. Verify the token is exactly three dot-separated segments with nothing extra.
// Node — pin the algorithm AND use the matching key import jwt from "jsonwebtoken"; try { const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ["HS256"], // never let the token choose }); } catch (err) { // err.name === "JsonWebTokenError" → bad signature // err.name === "TokenExpiredError" → see below }
"Token expired" / "jwt expired"
The current time is past the token's exp claim, a Unix timestamp in seconds. Decode the token and convert expto a date — if it's in the past, the token is simply too old and the fix is to obtain a fresh one (this is exactly what refresh tokens are for; see refresh token rotation).
If a token that should be valid reports as expired, suspect clock skew. If the machine that signed the token and the machine verifying it disagree about the time by more than a few seconds, freshly issued tokens can appear expired or not-yet-valid. Allow a small tolerance — most libraries support a clockTolerance orleeway option of 30–60 seconds — and make sure both servers sync their clocks via NTP.
"Malformed token" / "invalid token" / "jwt malformed"
The string isn't a structurally valid JWT. A well-formed JWT is three Base64URL segments separated by dots. Common causes:
- The
Bearerprefix was left on the token. Strip it before parsing: sendAuthorization: Bearer <token>but verify only the part after the space. - The value is empty,
null, or the literal string"undefined"because of a missing header or a frontend bug. - The token is URL-encoded (e.g.
%2Einstead of.) after being passed through a query string. - Standard Base64 (with
+,/, and=padding) was used instead of Base64URL.
"Audience / issuer mismatch"
The signature is fine and the token isn't expired, but verification still fails on a claim check. Your verifier is configured to require a specific aud(audience) or iss(issuer), and the token's value doesn't match. Decode the token, read its actual iss and aud, and compare them character-for-character with what your verifier expects.
This bites most often when moving between environments — a token minted by your staging identity provider carries a staging iss URL, which your production verifier rejects. The fix is to align the expected issuer and audience with the environment that actually issued the token, not to disable the check.
Algorithm mismatch (and the alg:none trap)
If your token is signed with RS256 but your verifier is configured for HS256 (or vice versa), verification fails. Always pin the expected algorithm explicitly on the server.
Critically, never derive the verification algorithm from the token's own header. Historic libraries that did this were vulnerable to the alg: none attack, where an attacker strips the signature, sets the header algorithm to none, and the server accepts the unsigned token as valid. Reject none outright and hard-code your allowed algorithms. The security guide covers this and other token attacks in depth.
"Token not active yet" (nbf)
Less common, but worth knowing: the nbf("not before") claim sets the earliest time a token is valid. If nbfis in the future relative to the verifying server's clock, the token is rejected as not yet active. Like the expiry case, this is usually clock skew between issuer and verifier — apply a small leeway and sync clocks via NTP.
Quick debugging checklist
- Decode the token first — read
alg,exp,iss,aud. - Confirm the verifier uses the matching key (same secret for HS, correct public key for RS/ES).
- Pin the algorithm server-side; reject
alg: none. - Strip the
Bearerprefix and any whitespace before parsing. - Convert
exp/nbfto a date; allow ~60s clock skew. - Check expected
issandaudmatch the issuing environment. - Still stuck? Reissue a fresh token and decode both side by side to spot the difference.
For the full set of rules a correct verifier should enforce, see the JWT validation guide, and the best practices guide for how to avoid these errors by design.