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

Scenarioerr.nameerr.messageerr.expiredAtinstanceof TokenExpiredErrorinstanceof JsonWebTokenError
Signed with expiresIn: '-10s', verified nowTokenExpiredError"jwt expired"real Date, e.g. 2026-10-02T12:40:46.000Ztruetrue
Signed with secret A, verified with secret BJsonWebTokenError"invalid signature"undefinedfalsetrue
Signed with notBefore: '10s', verified nowNotBeforeError"jwt not active"undefinedfalsetrue
Payload tampered, 1 base64 char flipped (lucky case)JsonWebTokenError"invalid signature"undefinedfalsetrue
Payload tampered, 1 base64 char flipped (unlucky case)SyntaxError"Bad control character in string literal in JSON at position 4 (line 1 column 5)"undefinedfalsefalse

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.

Scenariojsonwebtoken (Node)PyJWT (Python)
Expired tokenTokenExpiredError: "jwt expired"ExpiredSignatureError: "Signature has expired"
Wrong secretJsonWebTokenError: "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 allowanceclockTolerance: 5 (seconds)leeway=5 (seconds)
Expired/invalid share a base class?Yes: both extend JsonWebTokenErrorYes: 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

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.