Common JWT Errors and How to Fix Them (Node.js jsonwebtoken)

If you build authentication with the Node.js jsonwebtoken library, you will eventually meet the same four errors every developer meets: jwt malformed, invalid signature, jwt expired, and the generic invalid token. Each one has a precise cause, and once you know what the library is actually checking, the fix is usually a one-liner. This guide covers the cause and the fix for each error, plus a clean error-handling pattern to use in production middleware.

Error 1: "jwt malformed"

Cause: the value passed to jwt.verify() is not a JWT at all. A valid token has exactly three Base64Url sections separated by dots (header.payload.signature). This error means the library could not even split the string into three parts. The usual suspects:

  • Passing undefined or null because the header was missing
  • Passing the full header value "Bearer eyJhbGci..." — including the word Bearer — instead of just the token
  • Passing an empty string, a session id, or a token wrapped in quotes from JSON
  • The client sending the token in the wrong header or cookie name

Fix: extract and validate the token before verifying.

// BAD: verifies "Bearer eyJ..." — throws "jwt malformed"
const token = req.headers.authorization;
jwt.verify(token, SECRET);

// GOOD: strip the scheme and guard against missing headers
const authHeader = req.headers.authorization || '';
const [scheme, token] = authHeader.split(' ');

if (scheme !== 'Bearer' || !token) {
  return res.status(401).json({ error: 'Missing bearer token' });
}
const payload = jwt.verify(token, SECRET);

Still stuck? Paste the exact string you are verifying into a JWT decoder. If the decoder cannot parse it either, the problem is upstream — the client is not sending what you think it is sending.

Error 2: "invalid signature"

Cause: the token parsed fine, but the signature computed with your key does not match the signature on the token. In practice this means one of:

  • The signing service and verifying service use different secrets (different .env files, staging vs production values)
  • A trailing newline or space in the environment variable on one side
  • Algorithm mismatch — signed with RS256 (private key) but verified as HS256 with a shared secret, or vice versa
  • The secret was rotated, and old tokens signed with the previous secret are still in circulation
  • The token was tampered with — the payload was edited, so the original signature no longer matches

Fix: confirm both sides use byte-identical keys and the same algorithm, and pin the algorithm explicitly.

// Debug: compare secrets on both services without printing them
const crypto = require('crypto');
console.log('secret fingerprint:',
  crypto.createHash('sha256').update(process.env.JWT_SECRET).digest('hex').slice(0, 12),
  'length:', process.env.JWT_SECRET.length); // catches trailing \n

// Verify with the algorithm pinned
jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });

// RS256 setups: sign with the PRIVATE key, verify with the PUBLIC key
jwt.verify(token, publicKeyPem, { algorithms: ['RS256'] });

Error 3: "jwt expired"

Cause: the token's exp claim (a Unix timestamp in seconds) is earlier than the current time. This is not a bug — it is the library doing its job. It becomes a bug when it happens unexpectedly:

  • Tokens issued with a very short expiresIn (for example '60' means 60 seconds, not minutes)
  • Clock skew — the issuing server's clock is ahead of the verifying server's
  • exp set manually in milliseconds instead of seconds, making the token expire in 1970 from the verifier's point of view
  • The client never refreshes — it keeps replaying an access token long past its lifetime

Fix: handle TokenExpiredError distinctly so the client knows to refresh, and allow a small clock tolerance.

try {
  const payload = jwt.verify(token, SECRET, {
    algorithms: ['HS256'],
    clockTolerance: 10, // tolerate 10s of clock skew
  });
  req.user = payload;
  next();
} catch (err) {
  if (err.name === 'TokenExpiredError') {
    // err.expiredAt tells you exactly when it died
    return res.status(401).json({ error: 'token_expired' }); // client should refresh
  }
  return res.status(401).json({ error: 'invalid_token' });
}

Never "fix" this by setting ignoreExpiration: true in production — that turns every stolen token into a permanent credential.

Error 4: "invalid token" and Other JsonWebTokenError Messages

JsonWebTokenError: invalid token is the catch-all for tokens that split into three parts but fail deeper checks. Related messages from the same family include jwt audience invalid, jwt issuer invalid, invalid algorithm, and jwt signature is required. Causes and fixes:

  • Corrupted Base64 or JSON — the token was truncated in a database column or mangled by URL encoding. Store tokens in columns long enough (they can exceed 500 characters) and avoid double-encoding.
  • aud/iss mismatch — you passed audience or issuer options to jwt.verify() that do not match the claims in the token. Make sure sign and verify options agree exactly, including trailing slashes in URLs.
  • invalid algorithm — the token's alg is not in your algorithms whitelist. If you migrated from HS256 to RS256, old tokens will fail until they expire; that is expected.
  • signature is required — the token has an empty signature section (often an alg: none token). Reject it; this is an attack pattern, not a client bug.
// A complete middleware pattern that maps each error cleanly
function authMiddleware(req, res, next) {
  const [scheme, token] = (req.headers.authorization || '').split(' ');
  if (scheme !== 'Bearer' || !token) {
    return res.status(401).json({ error: 'missing_token' });
  }
  try {
    req.user = jwt.verify(token, SECRET, {
      algorithms: ['HS256'],
      issuer: 'https://api.example.com',
      audience: 'example-web-app',
    });
    return next();
  } catch (err) {
    const code =
      err.name === 'TokenExpiredError' ? 'token_expired' :
      err.name === 'NotBeforeError'    ? 'token_not_active_yet' :
      'invalid_token';
    return res.status(401).json({ error: code });
  }
}

A 60-Second Debugging Checklist

  • Log typeof token and its first 20 characters — is it really a token?
  • Decode it in a JWT decoder — do the header, payload, and exp look right?
  • Compare secret fingerprints (hash, not the secret itself) between signer and verifier
  • Confirm the alg in the header matches your algorithms whitelist
  • Check server clocks with date / NTP if expiry errors appear randomly

Frequently Asked Questions

What does "jwt malformed" mean?

The string passed to jwt.verify() is not a valid JWT — it does not have exactly three Base64Url sections separated by dots. Common causes are passing undefined, passing the whole "Bearer ..." header instead of just the token, or passing an empty string.

How do I fix "invalid signature" in jsonwebtoken?

The verification key does not match the signing key. Check that both services load the same JWT_SECRET, that there is no trailing whitespace or newline in the env value, and that the algorithm matches (HS256 shared secret vs RS256 key pair).

How should I handle "jwt expired" errors?

A TokenExpiredError means the exp claim is in the past. Return a 401 so the client can use its refresh token to get a new access token. Use clockTolerance for small clock-skew differences, and never disable expiration checks in production.

Try the Free JWT Decoder

Debugging a token error? Paste the JWT and instantly see its header, payload, and expiry — decoded entirely in your browser. No signup, no cost.

Related articles