JWT expired vs invalid signature (tested)
TokenExpiredError and JsonWebTokenError: invalid signature are both thrown by jwt.verify(), and both get caught by the same generic catch block, but they mean unrelated things: one token ran out of time, the other failed a cryptographic check. We signed and verified real tokens with jsonwebtoken 9.0.3 to capture the exact error shape of each, and found the clockTolerance boundary is stricter than it looks.
1. Two unrelated failures, same catch block
A token can be expired: it was signed correctly, but its exp claim is now in the past. Or it can be invalid: the signature doesn't match the header and payload for the secret you verified with, usually because the token was signed with a different secret, or the payload was edited after signing. jsonwebtoken represents these as two different classes: TokenExpiredError extends JsonWebTokenError. That inheritance means a catch (err) block that only checks err instanceof JsonWebTokenError cannot tell the two apart. See malformed vs invalid signature vs expired for a third failure mode, a bad token shape.
2. What we ran
Three scripts against jsonwebtoken 9.0.3 on Node 24.21.0: sign a token with a past exp and verify it; sign with one secret and verify with another; and sign a token that expires in exactly 1–2 seconds to test clockTolerance with a fixed, fake verification clock so timing jitter couldn't skew the result.
const jwt = require('jsonwebtoken');
const expired = jwt.sign({ foo: 'bar' }, 'secretA', { expiresIn: -10 });
jwt.verify(expired, 'secretA');
3. TokenExpiredError: exact name, message, and expiredAt
Verifying the token above threw an error with these exact properties:
| Property | Measured value |
|---|---|
| error.name | TokenExpiredError |
| error.message | jwt expired |
| error.expiredAt | a real Date instance, e.g. 2026-10-02T22:14:31.000Z — not a number |
| Object.keys(error) | ['name', 'message', 'expiredAt'] |
| err instanceof TokenExpiredError | true |
| err instanceof JsonWebTokenError | true |
expiredAt is built straight from the exp claim, so it reflects the token's stated expiry, not the moment you called verify().
4. JsonWebTokenError "invalid signature": no expiredAt at all
We signed with one secret and verified with a different one:
const token = jwt.sign({ foo: 'bar' }, 'secretA', { expiresIn: '1h' });
jwt.verify(token, 'secretB');
| Property | Measured value |
|---|---|
| error.name | JsonWebTokenError |
| error.message | invalid signature |
| error.expiredAt | undefined — the property doesn't exist on this error at all |
| Object.keys(error) | ['name', 'message'] |
| err instanceof TokenExpiredError | false |
| err instanceof JsonWebTokenError | true |
The token in this test had a valid, unexpired exp claim. The signature check runs independently of the expiry check, so a token can be simultaneously "not expired" and "invalid" — verify() still rejects it, with no mention of time anywhere in the error.
5. clockTolerance: the boundary is stricter than "within N seconds"
We fixed the verification clock with jsonwebtoken's own clockTimestamp option, so there was no real-time jitter, then signed tokens with exp set exactly 1 or 2 seconds before that fixed clock:
| Token expired by | clockTolerance | Result |
|---|---|---|
| 1 s | 0 (default) | TokenExpiredError |
| 1 s | 1 | TokenExpiredError (still throws) |
| 1 s | 2 | OK, payload returned |
| 2 s | 1 | TokenExpiredError |
| 2 s | 2 | TokenExpiredError (still throws) |
| 2 s | 3 | OK, payload returned |
A token expired by exactly N seconds still throws when clockTolerance equals N — tolerance has to be strictly greater than the overage, not merely equal to it. The installed package's verify.js confirms why, in the literal comparison it runs:
if (clockTimestamp >= payload.exp + (options.clockTolerance || 0)) {
return done(new TokenExpiredError('jwt expired', new Date(payload.exp * 1000)));
}
Because the check uses >=, setting clockTolerance to exactly the amount of clock drift you expect is not enough; give it at least one second of headroom beyond the worst skew you want to tolerate.
6. PyJWT gives different names and a different option
We ran the same two cases through PyJWT 2.15.1 in a Python 3.13 venv:
| Case | jsonwebtoken (Node) | PyJWT (Python) |
|---|---|---|
| Expired token | TokenExpiredError: "jwt expired" | ExpiredSignatureError: "Signature has expired" |
| Wrong secret | JsonWebTokenError: "invalid signature" | InvalidSignatureError: "Signature verification failed" |
| Tolerance option | clockTolerance (seconds, keyword option) | leeway (seconds, keyword argument) |
PyJWT's leeway=2 let a 1-second-expired token verify in our test, the same direction as Node's clockTolerance, though we did not check PyJWT's exact boundary case. If you're porting error-handling logic between a Node and a Python service, match on the error class, not the message string — the wording isn't shared between libraries.
7. Tell them apart in your catch block
Check the more specific class first, since TokenExpiredError also satisfies instanceof JsonWebTokenError:
try {
jwt.verify(token, secret);
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
// respond 401, token expired at err.expiredAt, client can refresh
} else if (err instanceof jwt.JsonWebTokenError) {
// invalid signature / malformed — respond 401, do not offer a refresh
} else {
throw err;
}
}
Treat the two differently on purpose: an expired token usually means "refresh and retry silently," while an invalid signature means the token was forged, edited, or signed for a different service, and should not be retried the same way. To inspect a token's claims and expiry by hand while debugging either case, use our JWT Decoder — it decodes client-side without sending the token anywhere. For the full set of jwt.verify() failure messages including malformed tokens, see our longer JWT verify errors breakdown.
Frequently asked questions
Does TokenExpiredError always have an expiredAt property?
Yes, in jsonwebtoken 9.0.3 we confirmed Object.keys(error) is exactly ['name', 'message', 'expiredAt'] on every expired-token error, and expiredAt is a real Date instance built from the token's exp claim.
Does JsonWebTokenError have an expiredAt property?
No. We checked Object.keys() on a wrong-secret JsonWebTokenError and got only ['name', 'message']. Accessing error.expiredAt on it returns undefined rather than throwing, so a careless check like 'if (err.expiredAt)' will silently treat every invalid-signature error as not-expired, which is true but easy to mis-read as 'this error has no expiry info at all'.
If I set clockTolerance to 2, will a token expired by exactly 2 seconds verify?
No, we measured this directly: it still throws TokenExpiredError. The installed package's verify.js uses clockTimestamp >= payload.exp + clockTolerance, so tolerance must be strictly greater than the overage, not equal to it.
Is JsonWebTokenError's 'invalid signature' message different for a wrong secret versus a tampered payload?
No. Both produce the identical JsonWebTokenError: invalid signature. The signature check recomputes the HMAC over whatever header and payload bytes are present and compares it to the token's third segment, so a wrong key and an edited payload fail the same comparison the same way.
Do Node's jsonwebtoken and Python's PyJWT throw the same error names?
No. We measured TokenExpiredError/JsonWebTokenError in jsonwebtoken 9.0.3 versus ExpiredSignatureError/InvalidSignatureError in PyJWT 2.15.1, with different message text in both cases. Match error-handling logic on each library's own class, not on a shared message string.
Written with AI assistance. Measured on 2026-10-02 with Node.js 24.21.0 and jsonwebtoken 9.0.3 (npm), reading verify.js in the installed package for the exact clockTolerance comparison, plus Python 3.13 and PyJWT 2.15.1 in a venv for the cross-library row. All error strings and the clockTolerance boundary are from running the code, not from documentation. PlainHeaders is our extension; the JWT Decoder tool is ours too. Node.js, jsonwebtoken and PyJWT are free.