JWT: TokenExpiredError vs invalid signature (tested)
jsonwebtoken throws two different errors that get lumped together: TokenExpiredError ("jwt expired") and JsonWebTokenError ("invalid signature"). They are not the same problem, and a generic catch block can hide which one you hit. We signed, expired, and tampered with real tokens on this box to see exactly what each failure looks like.
1. Two different failures, one error family
A JWT can fail verification for unrelated reasons. The token can be expired (it was valid, time ran out) or the token can be invalid (wrong secret, tampered payload, wrong algorithm). jsonwebtoken models this as a class hierarchy: TokenExpiredError and NotBeforeError both extend JsonWebTokenError. That inheritance is convenient for a catch-all, and a trap if you need to tell them apart.
If you need to decode a token by hand while debugging this, our JWT Decoder shows the header, payload and expiry without sending the token anywhere.
2. What we ran
Four scripts against jsonwebtoken 9.0.3 on Node 24.21.0: an expired token, a token verified with the wrong secret, a token with one base64 character flipped in the payload, and a clockTolerance check. We also installed PyJWT 2.15.1 to compare.
3. Exact errors, as thrown
| Scenario | err.name | err.message | err.expiredAt | instanceof TokenExpiredError | instanceof JsonWebTokenError |
|---|---|---|---|---|---|
Signed with expiresIn: '-10s', verified now | TokenExpiredError | "jwt expired" | real Date, e.g. 2026-10-02T12:40:46.000Z | true | true |
| Signed with secret A, verified with secret B | JsonWebTokenError | "invalid signature" | undefined | false | true |
Signed with notBefore: '10s', verified now | NotBeforeError | "jwt not active" | undefined | false | true |
| Payload tampered, 1 base64 char flipped (lucky case) | JsonWebTokenError | "invalid signature" | undefined | false | true |
| Payload tampered, 1 base64 char flipped (unlucky case) | SyntaxError | "Bad control character in string literal in JSON at position 4 (line 1 column 5)" | undefined | false | false |
err.expiredAt is a genuine Date instance (instanceof Date is true), not a string or timestamp, so you can format it directly.
4. Wrong secret vs tampered payload: same message, until it isn't
We confirmed the known trap: verifying with the wrong secret and verifying a token whose payload was edited both throw JsonWebTokenError: "invalid signature". From that message alone you cannot tell "someone used the wrong key" from "someone edited the claims". Treat both the same way: reject the token, do not try to recover data from it.
What we found that goes further: tampering the payload does not reliably produce "invalid signature" at all. We flipped one character at every position of a real payload segment (63 positions) and verified each result. Only 14 of 63 flips (22%) produced JsonWebTokenError: "invalid signature". The other 49 (78%) produced a raw, unwrapped SyntaxError from JSON.parse — not a JsonWebTokenError, not caught by instanceof jwt.JsonWebTokenError at all:
// idx=3 flipped: still valid JSON after decoding
JsonWebTokenError: invalid signature
// idx=5 flipped: no longer valid JSON
SyntaxError: Bad control character in string literal in JSON at position 4 (line 1 column 5)
err instanceof jwt.JsonWebTokenError // false
err instanceof SyntaxError // true
The reason is in jsonwebtoken's own source (verify.js): it calls decode(jwtString, {complete: true}), which JSON-parses the payload, before it calls jws.verify() to check the signature. If your tampering happens to break JSON syntax, the parse throws first and that raw error is returned as-is — the signature is never even checked. Only when the mangled bytes still happen to decode to syntactically valid JSON does the code reach the signature check and report "invalid signature". A catch (err) { if (err instanceof jwt.JsonWebTokenError) ... } will miss roughly three-quarters of single-byte payload tampering in our sample, and let a native SyntaxError escape instead.
5. Clock skew: clockTolerance works as documented
We signed a token that expired 2 seconds ago and verified it twice: once with no options (fails), once with clockTolerance: 5 (succeeds).
jwt.verify(token, secret);
// -> TokenExpiredError: jwt expired
jwt.verify(token, secret, { clockTolerance: 5 });
// -> returns the decoded payload, no error
5 seconds of tolerance covered the 2-second-expired token, as expected. Use a small clockTolerance (a few seconds) if your servers' clocks can drift, instead of padding every token's exp.
6. Why a generic catch still sees "expired"
We checked the prototype chain directly:
jwt.TokenExpiredError.prototype instanceof jwt.JsonWebTokenError // true
jwt.NotBeforeError.prototype instanceof jwt.JsonWebTokenError // true
So this common pattern catches expired tokens too, silently:
try {
jwt.verify(token, secret);
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
// true for TokenExpiredError AND NotBeforeError AND "invalid signature"
return res.status(401).send('invalid token');
}
}
That isn't wrong, but it throws away information a client could use (an expired token usually means "refresh and retry"; an invalid signature usually means "log out, something is wrong"). To tell them apart, check the specific name or class first, before the generic fallback:
try {
jwt.verify(token, secret);
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'token_expired', expiredAt: err.expiredAt });
}
if (err.name === 'NotBeforeError') {
return res.status(401).json({ error: 'token_not_active' });
}
if (err instanceof jwt.JsonWebTokenError) {
// covers wrong secret, tampered payload that still parses, wrong algorithm, "jwt malformed"
return res.status(401).json({ error: 'invalid_token' });
}
// a raw SyntaxError or other unexpected error reaches here - see section 4
console.error('unexpected jwt verify error', err);
return res.status(401).json({ error: 'invalid_token' });
}
For the broader set of malformed-token shapes (missing segments, bad base64, non-JSON header), see JWT malformed vs invalid signature vs expired. For how the token gets to the server in the first place, see Bearer token in the Authorization header.
7. Cross-language check: PyJWT (tested)
We installed PyJWT 2.15.1 on Python 3.13 and ran the same cases.
| Scenario | jsonwebtoken (Node) | PyJWT (Python) |
|---|---|---|
| Expired token | TokenExpiredError: "jwt expired" | ExpiredSignatureError: "Signature has expired" |
| Wrong secret | JsonWebTokenError: "invalid signature" | InvalidSignatureError: "Signature verification failed" |
| Tampered payload (same byte flip that broke Node's JSON parse) | SyntaxError (not a JsonWebTokenError, see section 4) | InvalidSignatureError: "Signature verification failed", consistently |
| Clock skew allowance | clockTolerance: 5 (seconds) | leeway=5 (seconds) |
| Expired/invalid share a base class? | Yes: both extend JsonWebTokenError | Yes: both extend InvalidTokenError |
The one real difference we measured: PyJWT gave the same "Signature verification failed" for the tampered payload every time we tried it, because it checks the HMAC signature before it parses the payload as JSON. Node's jsonwebtoken does it the other way around (section 4), so tampering can surface a raw SyntaxError instead of a library error. If you run both a Node and a Python service behind the same auth, don't assume identical error shapes from identical tampering.
8. Checklist
- Check
err.name === 'TokenExpiredError'(orerr instanceof jwt.TokenExpiredError) before a genericJsonWebTokenErrorcheck, or you'll never see it separately. - Don't try to distinguish "wrong secret" from "tampered payload" from the message. You can't; both say "invalid signature" when they say anything coherent at all.
- Add a fallback branch for errors that are neither — a flipped byte in the payload can throw a plain
SyntaxErrorthat skips yourJsonWebTokenErrorcheck entirely. - Use a small
clockTolerance(seconds, not minutes) if your servers' clocks aren't synced, rather than issuing longer-lived tokens. - Never branch application logic on the error message string; use
err.nameorinstanceof, since messages aren't a stable API across versions.
Frequently asked questions
What is the difference between TokenExpiredError and JsonWebTokenError?
TokenExpiredError means the token was valid but its exp time has passed; it carries err.expiredAt, a real Date. JsonWebTokenError (as 'invalid signature') means the token's signature doesn't match, from a wrong secret or an edited payload. In jsonwebtoken, TokenExpiredError is a subclass of JsonWebTokenError, so a generic instanceof check catches both.
Can I tell a tampered token from one signed with the wrong secret?
Not from the message. We signed a token with one secret and verified it with another, then separately tampered one byte of a valid token's payload. Both can throw JsonWebTokenError: 'invalid signature'. Treat any 'invalid signature' the same way: reject it.
Does catching JsonWebTokenError also catch expired tokens?
Yes. We checked the prototype chain directly: jwt.TokenExpiredError.prototype instanceof jwt.JsonWebTokenError is true, and so is jwt.NotBeforeError.prototype. If you need to treat expiry differently (e.g. tell the client to refresh), check err.name === 'TokenExpiredError' before your generic catch, not after.
How do I allow for clock drift between servers when checking expiry?
Pass clockTolerance (seconds) to jwt.verify. We signed a token that expired 2 seconds ago: verifying with no options threw TokenExpiredError, and verifying with { clockTolerance: 5 } returned the decoded payload successfully. PyJWT's equivalent option is leeway, tested the same way.
Does a tampered JWT payload always produce an 'invalid signature' error?
No, and this is easy to miss. We flipped one base64 character at every position of a payload and verified each result: only 22% produced JsonWebTokenError 'invalid signature'. The rest threw a raw SyntaxError from JSON.parse, because jsonwebtoken decodes the payload as JSON before it checks the signature. That SyntaxError is not a JsonWebTokenError and will slip past an instanceof check that only tests for that class.
Written with AI assistance. Measured on 2026-10-02 with Node.js 24.21.0 and jsonwebtoken 9.0.3 (npm), plus Python 3.13 and PyJWT 2.15.1 for the cross-language table. All error strings and instanceof results below are from running the code, not the docs. PlainHeaders is our extension; Node.js, jsonwebtoken and PyJWT are free. The JWT Decoder tool is ours too.