jwt malformed, invalid signature, jwt expired (tested)
Node's jsonwebtoken package throws three different errors from jwt.verify(): JsonWebTokenError: jwt malformed, JsonWebTokenError: invalid signature, and TokenExpiredError: jwt expired. We signed and verified real tokens with jsonwebtoken 9.0.3 to pin down exactly which input produces which error, what the .expiredAt property looks like, and how clockTolerance changes the outcome. The token itself is usually sent in an Authorization: Bearer header.
1. The three errors at a glance
| Error you see | error.name | Typical cause |
|---|---|---|
| JsonWebTokenError: jwt malformed | JsonWebTokenError | The string isn't 3 dot-separated parts (0, 1, or more than 2 dots) |
| JsonWebTokenError: invalid signature | JsonWebTokenError | 3 parts, but the signature doesn't match the header+payload for the given secret |
| TokenExpiredError: jwt expired | TokenExpiredError | Signature is valid, but the exp claim is in the past |
All three, plus a fourth NotBeforeError (for a future nbf claim), extend the same base JsonWebTokenError, which extends Error. Section 6 covers why that matters for your catch block.
2. "jwt malformed" — exactly which inputs trigger it
We called jwt.verify(token, 'secret') with seven different bad inputs and logged error.name and error.message for each:
| Input | error.name | error.message |
|---|---|---|
'abc' (no dots) | JsonWebTokenError | jwt malformed |
'abc.def' (1 dot) | JsonWebTokenError | jwt malformed |
' ' (whitespace, no dots) | JsonWebTokenError | jwt malformed |
'!!!.###.$$$' (2 dots, 3 parts, but not valid base64url/JSON) | JsonWebTokenError | invalid token |
'' (empty string) | JsonWebTokenError | jwt must be provided |
undefined | JsonWebTokenError | jwt must be provided |
null | JsonWebTokenError | jwt must be provided |
No input produced a plain TypeError; jsonwebtoken wraps every case in JsonWebTokenError. But "jwt malformed" is not the only message you can get back from a bad token: it is specifically what you get when token.split('.') does not yield exactly 3 parts. If the string has exactly 3 parts but they don't decode to valid base64url JSON, the message is the different invalid token, from this check in verify.js:
const parts = jwtString.split('.');
if (parts.length !== 3){
return done(new JsonWebTokenError('jwt malformed'));
}
// ...
decodedToken = decode(jwtString, { complete: true });
if (!decodedToken) {
return done(new JsonWebTokenError('invalid token'));
}
And a falsy token ('', undefined, null) is caught even earlier, before the split, giving jwt must be provided instead of either of those. If your error-handling code matches on the string "malformed" alone, it will miss invalid token and jwt must be provided — match on the error class instead (section 6).
3. "invalid signature" — wrong secret and tampered payloads look the same
We signed a token with correct-secret, then verified it two different ways:
const token = jwt.sign({ foo: 'bar' }, 'correct-secret');
jwt.verify(token, 'wrong-secret');
// JsonWebTokenError: invalid signature
Then we left the secret alone and tampered with the payload instead: base64url-decoded the middle segment, changed a field, re-encoded it, and swapped it back in — without re-signing:
const parts = token.split('.');
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
payload.foo = 'TAMPERED';
const tampered = parts[0] + '.' +
Buffer.from(JSON.stringify(payload)).toString('base64url') + '.' + parts[2];
jwt.verify(tampered, 'correct-secret');
// JsonWebTokenError: invalid signature
Same error, same message, in both cases: JsonWebTokenError: invalid signature. That's the point of an HMAC signature — the library recomputes it over whatever header+payload bytes are actually present and compares it to the third segment. Change either the secret used to verify or the bytes being signed, and the comparison fails the same way. You cannot tell from the error alone whether the client used the wrong secret or someone edited the payload in transit.
4. "jwt expired" (TokenExpiredError) — the .expiredAt property and clockTolerance
We signed a token that expires almost immediately, waited past that point, then verified:
const token = jwt.sign({ foo: 'bar' }, 'correct-secret', { expiresIn: '1ms' });
// ... wait 50ms ...
jwt.verify(token, 'correct-secret');
Result: TokenExpiredError, message jwt expired, and an expiredAt property that is a real Date instance (not a timestamp number):
error.name // 'TokenExpiredError'
error.message // 'jwt expired'
error.expiredAt // 2026-10-02T12:34:24.000Z (instanceof Date === true)
Signing with an explicit past exp claim (jwt.sign({ foo: 'bar', exp: nowSeconds - 100 }, secret)) gives the identical error shape. exp is read in whole seconds, so sub-second precision isn't meaningful either way.
The clockTolerance option (seconds) forgives small clock drift between signer and verifier. We signed a token with exp set 2 seconds in the past, then verified it twice:
jwt.verify(token, secret);
// throws TokenExpiredError: jwt expired
jwt.verify(token, secret, { clockTolerance: 5 });
// succeeds: returns { foo: 'bar', exp: ..., iat: ... }
With no tolerance it throws; with clockTolerance: 5 the same 2-seconds-expired token verifies successfully and returns the decoded payload. Use this for clock skew between servers, not as a way to extend real session lifetimes.
5. "not enough segments" — not in this version
Some JWT error messages you'll find in search results (and in other languages' JWT libraries) say "not enough segments" for a token that isn't split into 3 parts. We grepped the installed jsonwebtoken@9.0.3 source for that exact phrase and it does not appear anywhere in the package. The only messages the library's own verify.js throws for a bad token shape are the ones measured in section 2: jwt malformed, invalid token, and jwt must be provided. If you're seeing "not enough segments" in your logs, it's coming from a different library (older PyJWT and some other-language JWT ports use that wording) — not from Node's jsonwebtoken.
6. Catching these safely: the error class hierarchy
Reading the installed package's source confirms the inheritance chain:
JsonWebTokenError extends Error
TokenExpiredError extends JsonWebTokenError
NotBeforeError extends JsonWebTokenError
That means err instanceof jwt.JsonWebTokenError is true for a TokenExpiredError too — we confirmed this directly (instanceof returned true in our test run). If you want to treat expiry differently from a bad signature, check the more specific class first:
try {
jwt.verify(token, secret);
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
// respond 401 "token expired", maybe prompt a refresh
} else if (err instanceof jwt.JsonWebTokenError) {
// malformed / invalid signature / invalid token — respond 401 "invalid token"
} else {
throw err; // something else entirely
}
}
Checking instanceof jwt.JsonWebTokenError first would swallow the expired-token case into the generic branch, since the check also matches TokenExpiredError instances.
7. decode() has none of these checks
jwt.decode(token) just base64url-decodes the header and payload segments and returns them as an object. It does not check the signature and does not check exp. We called it on both tokens from earlier sections:
jwt.decode(tamperedToken);
// { foo: 'TAMPERED', iat: 1790944464 } -- no error, no signature check
jwt.decode(expiredToken);
// { foo: 'bar', iat: 1790944464, exp: 1790944464 } -- no error, no expiry check
Both returned the claims with no error at all — the tampered field and the expired timestamp decode just fine, because decode() never touches the signature. That's fine when you only need to read claims you already trust (showing a username in a UI, say) but it is not verification. Our jwt-decoder tool does the same client-side decode-only inspection — paste a token to see its header and payload, with iat/nbf/exp converted to UTC dates, without needing the signing secret. For anything that gates access, use jwt.verify() with the real secret or key, server-side.
Frequently asked questions
Why does jwt.verify() say "jwt malformed" for a token with only one dot?
jsonwebtoken splits the token on '.' before doing anything else. If the result isn't exactly 3 parts (header, payload, signature), it throws JsonWebTokenError: jwt malformed immediately, without trying to decode anything.
Is "invalid signature" caused by a wrong secret or a tampered token?
Either. We measured both: verifying with the wrong secret, and verifying a token whose payload was edited after signing without re-signing, produced the identical JsonWebTokenError: invalid signature. The error can't tell you which happened.
What type is error.expiredAt on a TokenExpiredError?
A real JavaScript Date instance, not a number. We confirmed expiredAt instanceof Date is true on jsonwebtoken 9.0.3. Use it directly, e.g. in a log message, without converting from a timestamp.
Does jsonwebtoken still say "not enough segments"?
No, not in the installed 9.0.3. We grepped the package source and that phrase doesn't appear; a token with the wrong number of parts gets jwt malformed instead. If you see "not enough segments", it's from a different JWT library.
Can I catch TokenExpiredError and JsonWebTokenError separately?
Yes, but check TokenExpiredError (and NotBeforeError) first. Both extend JsonWebTokenError, so err instanceof jwt.JsonWebTokenError is true for all of them; checking the base class first will catch expired tokens too and you'll never reach the more specific branch.
Written with AI assistance. Measured on 2026-10-02 with Node.js 24.21.0 and jsonwebtoken 9.0.3 (the most downloaded JWT library on npm), calling jwt.sign() and jwt.verify() directly and reading the installed package's source for the error classes and exact message strings. The jwt-decoder tool is ours and free; Node.js and jsonwebtoken are open source.