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:

PropertyMeasured value
error.nameTokenExpiredError
error.messagejwt expired
error.expiredAta real Date instance, e.g. 2026-10-02T22:14:31.000Z — not a number
Object.keys(error)['name', 'message', 'expiredAt']
err instanceof TokenExpiredErrortrue
err instanceof JsonWebTokenErrortrue

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');
PropertyMeasured value
error.nameJsonWebTokenError
error.messageinvalid signature
error.expiredAtundefined — the property doesn't exist on this error at all
Object.keys(error)['name', 'message']
err instanceof TokenExpiredErrorfalse
err instanceof JsonWebTokenErrortrue

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 byclockToleranceResult
1 s0 (default)TokenExpiredError
1 s1TokenExpiredError (still throws)
1 s2OK, payload returned
2 s1TokenExpiredError
2 s2TokenExpiredError (still throws)
2 s3OK, 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:

Casejsonwebtoken (Node)PyJWT (Python)
Expired tokenTokenExpiredError: "jwt expired"ExpiredSignatureError: "Signature has expired"
Wrong secretJsonWebTokenError: "invalid signature"InvalidSignatureError: "Signature verification failed"
Tolerance optionclockTolerance (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.