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 seeerror.nameTypical cause
JsonWebTokenError: jwt malformedJsonWebTokenErrorThe string isn't 3 dot-separated parts (0, 1, or more than 2 dots)
JsonWebTokenError: invalid signatureJsonWebTokenError3 parts, but the signature doesn't match the header+payload for the given secret
TokenExpiredError: jwt expiredTokenExpiredErrorSignature 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:

Inputerror.nameerror.message
'abc' (no dots)JsonWebTokenErrorjwt malformed
'abc.def' (1 dot)JsonWebTokenErrorjwt malformed
' ' (whitespace, no dots)JsonWebTokenErrorjwt malformed
'!!!.###.$$$' (2 dots, 3 parts, but not valid base64url/JSON)JsonWebTokenErrorinvalid token
'' (empty string)JsonWebTokenErrorjwt must be provided
undefinedJsonWebTokenErrorjwt must be provided
nullJsonWebTokenErrorjwt 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.