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
undefinedornullbecause 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
.envfiles, 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
expset 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
audienceorissueroptions tojwt.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
algis not in youralgorithmswhitelist. 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: nonetoken). 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 tokenand its first 20 characters — is it really a token? - Decode it in a JWT decoder — do the header, payload, and
explook right? - Compare secret fingerprints (hash, not the secret itself) between signer and verifier
- Confirm the
algin the header matches youralgorithmswhitelist - Check server clocks with
date/ NTP if expiry errors appear randomly
Frequently Asked Questions
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.
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).
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.