JsonWebTokenError: invalid signature & jwt malformed

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:

// ❌ 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

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

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.

JWT Decoder JSON Formatter All Error References
About the author

Pasindu Ishan is a software developer based in Sri Lanka. He builds privacy-first developer tools at JSON Dev Tools.