Quick answer
invalid signature means the secret or key verifying the token does not match the one that signed it. jwt malformed means the string is not a valid three-part JWT — usually it still has the Bearer prefix, is undefined, or is quoted.
The exact error strings
Both come from the Node jsonwebtoken library when you call jwt.verify(). The thrown objects look like this:
JsonWebTokenError: invalid signature
at /app/node_modules/jsonwebtoken/verify.js:171:19
name: 'JsonWebTokenError',
message: 'invalid signature'
JsonWebTokenError: jwt malformed
at module.exports [as verify] (/app/node_modules/jsonwebtoken/verify.js:70:17)
name: 'JsonWebTokenError',
message: 'jwt malformed'
They share the same name (JsonWebTokenError) but mean very different things. jwt malformed fails before any cryptography happens — the string is not even shaped like a JWT. invalid signature fails during verification — the token is well-formed, but the signature does not check out against your key.
jsonwebtoken verify() errors explained
Every error on this page is thrown by jsonwebtoken's jwt.verify() — never by jwt.sign(), which only creates tokens. verify() does two jobs: it re-computes the signature with your key and it checks the claims (exp, nbf). A failure in the first job is invalid signature; a malformed input is jwt malformed; a claim problem is a different error class such as TokenExpiredError.
Which key verify() needs depends on the signing algorithm. With HS256 (the default) the same JWT_SECRET string both signs and verifies. With RS256 or ES256 you sign() with a private key and verify() with the matching public key. The token also travels a specific path — from the client's Authorization header as a Bearer token to your verify() call — and a mistake anywhere on that path produces one of these errors.
How a JWT is structured
A JSON Web Token is three Base64URL-encoded segments joined by dots: header.payload.signature. The header and payload are just encoded JSON you can read with any decoder; the signature is an HMAC or RSA/ECDSA signature over the first two parts. Verification recomputes that signature with your key and compares it. If the recomputed value differs, you get invalid signature; if the string cannot be split into three valid parts at all, you get jwt malformed.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header
.eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJBbWkifQ ← payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQ ← signature
(Shown split across lines for clarity — a real JWT is a single string with no line breaks.)
Fix: jwt malformed
jwt malformed is almost always a token-handling bug, not a crypto problem. Log the exact value you pass to verify() and check these in order:
- The
Bearerprefix is still attached — you passed the wholeAuthorizationheader instead of just the token. - The token is
undefinedor empty — the header or cookie was missing and you passedundefinedstraight in. - The token is wrapped in quotes — e.g. read from JSON or an env var as
"eyJ..."including the quote characters. - It is not a JWT — an opaque session ID, an API key, or a Base64 blob that is not three dotted parts.
// ❌ Passing the full header value → jwt malformed
const token = req.headers.authorization; // "Bearer eyJ..."
jwt.verify(token, secret);
// ✅ Strip the scheme and verify only the token
const token = req.headers.authorization?.split(' ')[1]; // "eyJ..."
if (!token) return res.status(401).json({ error: 'No token provided' });
jwt.verify(token, secret);
Fix: invalid signature
invalid signature means the token is genuine JWT shape but was signed with a different key than the one verifying it. The cause depends on your algorithm.
HS256 (shared secret)
The same secret string must sign and verify. The usual culprit is a mismatched JWT_SECRET between environments — the token was issued on one server and verified on another with a different value, or a fallback default like process.env.JWT_SECRET || 'dev' kicked in. Whitespace and quotes in the env var count as part of the secret.
// Signing and verifying MUST use the identical secret
const token = jwt.sign({ sub: userId }, process.env.JWT_SECRET);
jwt.verify(token, process.env.JWT_SECRET);
// Common traps:
// - process.env.JWT_SECRET is undefined in prod → falls back to a different value
// - .env has JWT_SECRET="abc" → the quotes become part of the secret
// - trailing newline/space copied into a secrets manager
RS256 / ES256 (key pair)
Asymmetric algorithms sign with a private key and verify with the matching public key. invalid signature means the public key does not pair with the private key that signed the token — often the wrong key file, a key rotated without re-issuing tokens, or PEM formatting mangled by env-var handling (lost newlines).
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
// If the PEM came from an env var, restore real newlines:
const publicKey = process.env.JWT_PUBLIC_KEY.replace(/\\n/g, '\n');
After changing any secret or key, re-issue tokens: every token signed with the old key will keep failing verification until it is replaced.
JWT_SECRET environment variable issues
In practice, the single most common cause of invalid signature is a JWT_SECRET that is subtly different at sign time versus verify time. Three traps account for most cases:
Quotes become part of the secret
In a .env file, quoting changes the value. JWT_SECRET="abc" may load as the five characters "abc" (with quotes) under some loaders, while JWT_SECRET=abc loads as abc. If one environment quotes and another does not, the secrets differ and verification fails:
# .env — pick one style and use it everywhere
JWT_SECRET=abc # value is: abc
JWT_SECRET="abc" # value may be: "abc" (quotes included by some parsers)
# A trailing space or newline also counts:
JWT_SECRET=abc␣ # value is: "abc " — different secret, invalid signature
See the .env file format explained for the exact quoting rules — this is where most “works locally, fails in prod” signature mismatches start.
The fallback default trap
A pattern like process.env.JWT_SECRET || 'dev-secret' silently signs (or verifies) with 'dev-secret' whenever the env var is missing. If production forgets to set JWT_SECRET, tokens signed locally with the real secret fail against the fallback — or worse, everything “works” on an insecure hard-coded secret. Fail loudly instead:
// ❌ Hides a missing secret and causes invalid signature across envs
const secret = process.env.JWT_SECRET || 'dev-secret';
// ✅ Crash at startup if the secret is not configured
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET is not set');
The secret is undefined where you verify
If process.env.JWT_SECRET is undefined at the point of verify() — because dotenv loaded too late or the variable was never set on the server — the comparison cannot succeed. This overlaps with process.env.X is undefined: confirm the variable is actually populated (log Boolean(process.env.JWT_SECRET), never the value itself) before chasing crypto causes.
Related JsonWebTokenError messages
jwt expired— aTokenExpiredError(a subclass), not a signature problem. The signature is valid but theexpclaim is in the past. Issue a fresh token or use a refresh-token flow.jwt not active— aNotBeforeError: thenbf(not-before) claim is still in the future. Combined with spurious earlyjwt expirederrors, this usually means clock skew — the signing and verifying servers' clocks have drifted. Allow a few seconds of tolerance withjwt.verify(token, secret, { clockTolerance: 5 })and sync server clocks with NTP.jwt must be provided— you calledverify()withundefined. Guard for a missing token before verifying.invalid token— the segments decode but the header/payload is not valid JSON.invalid algorithm— the token'salgis not in thealgorithmsarray you allowed inverify().jwt signature is required— the token has no signature segment (analg: nonetoken), whichverify()rejects.
Handle the errors correctly
Branch on the error type so the client gets an accurate reason and you never leak a 500 for an expected auth failure:
try {
const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });
req.user = payload;
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
if (err.name === 'JsonWebTokenError') {
// invalid signature, jwt malformed, invalid token, etc.
return res.status(401).json({ error: 'Invalid token' });
}
return res.status(500).json({ error: 'Auth check failed' });
}
Inspect a token safely
To see what is inside a token without verifying it, decode it — jwt.decode() reads the payload and never throws invalid signature. This is the fastest way to confirm the alg, iss, and exp you are actually receiving. You can paste a token into the JWT Decoder to read its header and payload in your browser (decoding happens locally and is safe; see is it safe to decode a JWT online?). Remember: decoding is not verifying — never trust a token's claims until verify() passes.
Never trust jwt.decode() for authentication
jwt.decode() reads a token's claims without checking the signature at all. That makes it safe for inspection — but dangerous if you use it to authenticate. Because the header and payload are just Base64URL-encoded JSON, anyone can craft a token with "role": "admin" and decode() will happily return it. Only jwt.verify() proves the token was genuinely issued by you.
// ❌ CRITICAL VULNERABILITY — decode does not verify the signature
const user = jwt.decode(token);
if (user.role === 'admin') grantAccess(); // attacker can forge this
// ✅ verify() checks the signature before you trust any claim
const user = jwt.verify(token, secret, { algorithms: ['HS256'] });
if (user.role === 'admin') grantAccess();
Rule of thumb: decode to read, verify to trust. Never make an authorization decision on the output of decode(), and always pin the allowed algorithms in verify() so an attacker cannot downgrade the token to alg: none.
Debugging checklist
- ✓ Log the exact string passed to
verify()— is it three dotted parts? - ✓ Strip the
Bearerprefix and any surrounding quotes before verifying - ✓ Confirm the same secret (HS256) or matching key pair (RS256) signs and verifies
- ✓ Check the secret/key is identical across environments, with no trailing whitespace or quotes
- ✓ Restore real newlines in PEM keys loaded from env vars (
\n→ newline) - ✓ Re-issue tokens after any secret or key change
- ✓ Branch on
err.nameto distinguish expired from invalid
Frequently Asked Questions
What does JsonWebTokenError: invalid signature mean?
It means the signature on the token does not match the one your server computes when verifying it. The token was signed with one secret or private key, but jwt.verify() is checking it with a different secret or key. Common causes are a mismatched JWT_SECRET between environments, an extra space or quote in the secret, or verifying an RS256 token with the wrong public key.
What does jwt malformed mean?
jwt malformed means the string you passed to jwt.verify() is not a structurally valid JWT. A JWT must be three Base64URL segments separated by dots (header.payload.signature). You get this error when the value is undefined, an empty string, still wrapped in the Bearer prefix, double-quoted, or is some other token format entirely.
How do I fix invalid signature when the token works locally but not in production?
The signing secret differs between the two environments. The token was issued by a server using your local JWT_SECRET, but the production server verifies it with a different value (or a default fallback). Ensure the exact same secret is set in both environments, with no trailing whitespace or quotes, and re-issue tokens after changing it — old tokens signed with the old secret will still fail.
Why do I get jwt malformed when I send a Bearer token?
Because you passed the whole Authorization header value, including the Bearer prefix, to jwt.verify(). The verify function expects only the token itself. Strip the prefix first: const token = req.headers.authorization?.split(' ')[1]; then verify token. Passing Bearer eyJ... instead of eyJ... triggers jwt malformed.
What is the difference between invalid signature and jwt expired?
Both are thrown by jwt.verify(), but they are different classes. invalid signature is a JsonWebTokenError meaning the token's signature does not verify against your key. jwt expired is a TokenExpiredError meaning the signature is valid but the exp claim is in the past. A valid-but-expired token still proves it was genuinely issued by you; an invalid-signature token does not.
Can decoding a JWT without verifying it cause these errors?
No. jwt.decode() only reads the payload and never checks the signature, so it never throws invalid signature. These errors come specifically from jwt.verify(), which validates the signature and claims. Decoding is useful for inspecting a token's contents, but you must verify before trusting it for authentication.
Need to inspect a token?
Decode a JWT's header and payload in your browser — nothing is uploaded to a server.