Notes

JsonWebTokenError: jwt malformedwhat your verifier received instead of a token

JWT Doctor

Your Node backend calls jwt.verify() from the jsonwebtoken package, and instead of a payload it throws this:

JsonWebTokenError: jwt malformed

The message is narrower than it sounds. jsonwebtoken splits the value at every period, and unless it gets exactly three parts, it stops right there. It has not read a header, looked at an algorithm or used your secret. So the fix is never in the key, and changing JWT_SECRET will not touch this error. The useful question is what your verifier received instead of a token, and the answer is usually in the first few characters of that value.

Other libraries run the same count and say it differently. jose throws JWSInvalid with "Invalid Compact JWS" when a compact token does not have three parts, and PyJWT raises DecodeError with "Not enough segments" when it has fewer. The code on this page uses jsonwebtoken, because this exact message comes from it.

The check behind the message

A signed JWT in compact form is three base64url strings joined by periods: header, payload and signature. RFC 7515 puts it as "exactly two delimiting period characters". jsonwebtoken checks the value in this order before it decodes anything, with the same checks and messages in version 8.5.1 and in 9.0.3, the current release:

jsonwebtoken 9.0.3, verify.js (shortened)

if (!jwtString) {
  return done(new JsonWebTokenError('jwt must be provided'));
}
if (typeof jwtString !== 'string') {
  return done(new JsonWebTokenError('jwt must be a string'));
}
const parts = jwtString.split('.');
if (parts.length !== 3) {
  return done(new JsonWebTokenError('jwt malformed'));
}

So jwt malformed means one specific thing: a non-empty string with a number of periods other than two. A missing value gets a different message, and so does a value with three parts that will not decode. A token that still has Bearer in front of it is the common example of that second kind: the decoder in the jws package accepts only base64url characters, the space fails that test, and jsonwebtoken reports invalid token.

What reached jwt.verify(), split at the periods, and the message each value gets jsonwebtoken splits the value at every period and throws jwt malformed unless it gets exactly three parts. A token looks like eyJhbGci, a period, eyJzdWIi, a period, SflKxwRJ: three parts, so the count passes and decoding comes next, before any key is used. Values that get jwt malformed: the secret superSuperSecret sent as the token, one part; the text null from Bearer ${token} before login, one part; the word Bearer from split at the space with index 0, one part; the payload alone from split at the period with index 1, one part; an encrypted JWE such as a NextAuth session cookie, five parts. Values stopped by other checks: no value at all (undefined, null or an empty string) gives jwt must be provided before the count; an object instead of a string gives jwt must be a string before the count; a token with the Bearer prefix still in front has three parts, passes the count, and fails decoding with invalid token. jwtString.split('.').length !== 3 THE CHECK WHAT jwt.verify() EXPECTS eyJhbGci… eyJzdWIi… SflKxwRJ… 3 parts header, payload, signature: two periods count passes, decoding comes next NOT THREE PARTS: jwt malformed superSuperSecret 1 part the secret sent as the token (2018 question) jwt malformed null 1 part the text null: Bearer ${token} before login jwt malformed Bearer 1 part split(' ')[0]: the scheme, not the token jwt malformed eyJzdWIiOi… 1 part split('.')[1]: the payload alone (2016 question) jwt malformed eyJ… … … … … 5 parts an encrypted JWE, e.g. a NextAuth session cookie jwt malformed OTHER CHECKS, OTHER MESSAGES no value not split undefined, null or '': checked before the count jwt must be provided { token: … } not split an object, not a string: checked before the count jwt must be a string Bearer eyJ… eyJ… Sfl… 3 parts Bearer prefix kept: the count passes, decoding fails invalid token
jsonwebtoken splits the value at every period and throws jwt malformed unless it gets exactly three parts. Most rows are values from the questions and documentation cited below, shortened; the others follow from the checks in verify.js in jsonwebtoken 9.0.3 and 8.5.1, which also give the order of checks and the messages. The decoding rule for the last row comes from the jws package it uses.

The count also tells you where to look. One part means the value has no period at all: a word, a key, or one piece of a token. Five parts is the shape of an encrypted token, which RFC 7516 defines with four periods. If the value started out as a token, any other count means characters were lost or changed on the way to your verifier. One answer on the 2018 question below had exactly that: a token whose two periods had been replaced with underscores.

01

Something that was never a token

The most viewed question about this error, from 2018 (about 171,000 views), is this case. The middleware read the token from an x-access-token header, and in Postman the asker had put the secret there, superSuperSecret. A secret has no periods, so it is one part. The accepted answer: you cannot pass any value as a token; create one with jwt.sign() and send it as Authorization: Bearer followed by the token. Another answer points at the Postman version of the same mistake: check that the environment variable meant to hold the token actually holds it.

Supabase's newer API keys land here too. Its documentation says publishable and secret keys (sb_publishable_..., sb_secret_...) are not JWTs, that anything trying to verify one as a JWT fails, and that they belong in the apikey header, not in Authorization: Bearer. The value your own backend should verify is the signed-in user's access token, which Supabase documents as a JWT sent in Authorization: Bearer.

Fix: call your real login route, take the token it returns, and send that. A signed token has two periods; a secret, a new sb_ API key or a placeholder does not.

Supabase's legacy anon and service_role keys are the exception. The same documentation lists them as long-lived JWTs, the legacy versions of the publishable and secret keys. They have two periods, pass the count and never give jwt malformed. Sent in place of the user's token, they fail a later check, or, if your verifier accepts them, what it verified is a project key and not a signed-in user.

02

The text "null" or "undefined"

This one hides well, because the header looks almost right. localStorage.getItem() returns null when the key does not exist, and a template literal turns null into the text "null" and undefined into "undefined". A client that builds the header before anyone has signed in sends this:

Sends Authorization: Bearer null

const token = localStorage.getItem('token'); // null: nothing saved yet
fetch('/api/me', {
  headers: { Authorization: `Bearer ${token}` },
});

The server takes the part after the space, gets the four letters null, finds no period and throws jwt malformed. A real null or an empty string would have stopped one check earlier, with jwt must be provided. An answer on the 2018 question with 15 votes says a null token gives jwt malformed. In jsonwebtoken 8.5.1 and 9 that holds for the text "null"; the value null stops at jwt must be provided.

Send the header only when there is a token

const token = localStorage.getItem('token');
const headers = token ? { Authorization: `Bearer ${token}` } : {};
fetch('/api/me', { headers });

The same thing happens with undefined from a variable that was never set, for example state that has not loaded yet when the first request fires. The rule is the same: no token, no header.

A saved value can carry the same text. localStorage stores strings only, so localStorage.setItem('token', data.token) with no token in the login response saves the text "undefined", and the token ? check above lets it through. Save the token only when there is one:

Never store a missing token

if (data.token) localStorage.setItem('token', data.token);

03

The wrong piece of the Authorization header

RFC 6750 defines the header as the word Bearer, one or more spaces, and the token. Splitting it by hand goes wrong in two classic ways. In the 2016 question (about 35,000 views) the middleware used authorization.split('.')[1], which is only the payload, a string without periods; the accepted answer says to split at the space instead. The mirror mistake, split(' ')[0], returns the word Bearer. Both are one part.

Read the header in one function, and refuse a missing token before jwt.verify() ever sees it:

Express middleware: one place reads the header

function bearerToken(req) {
  const header = req.headers.authorization || '';
  const match = /^Bearer +(\S+)$/i.exec(header);
  return match ? match[1] : null;
}

function requireUser(req, res, next) {
  const token = bearerToken(req);
  if (!token) {
    res.set('WWW-Authenticate', 'Bearer realm="api"');
    return res.status(401).end();
  }
  try {
    req.user = jwt.verify(token, secret, { algorithms: ['HS256'] });
    return next();
  } catch (err) {
    console.warn('jwt rejected:', err.message);
    res.set('WWW-Authenticate', 'Bearer error="invalid_token"');
    return res.status(401).end();
  }
}

Three details in it come from the specs. Node lowercases incoming header names, so req.headers.Authorization is always undefined, which would end in jwt must be provided. RFC 9110 makes the scheme name case-insensitive, hence the i flag: bearer is as valid as Bearer. And RFC 6750 asks for a 401 with a WWW-Authenticate: Bearer header, with error="invalid_token" for a token that is malformed and no error code when no token was sent at all.

The usual shortcuts work for the usual header and miss the edges: split(' ')[1] returns an empty string when there are two spaces, and replace('Bearer ', ''), suggested in a 2024 answer on the 2018 question, leaves a lowercase bearer prefix in place.

04

A real token, but not a signed JWT

RFC 7519 lets a JWT be signed (a JWS, three parts) or encrypted (a JWE, five parts and four periods). jwt.verify() needs the three-part signed form, so a JWE fails the count before anything else. Two popular setups hand you something other than a signed JWT without making it obvious:

  • NextAuth's session cookie. NextAuth's documentation says its JSON Web Tokens are encrypted (JWE) by default and recommends keeping it that way. On the Next.js server, its getToken() helper decrypts and verifies the session for you, given the same secret as your NextAuth configuration. Forward the raw cookie to another backend that runs jwt.verify(), and that backend sees five parts.
  • Auth0 access tokens without an audience. Auth0's documentation lists the access token for its /userinfo endpoint, requested with no audience, as opaque, and access tokens for a custom API registered in the Auth0 Dashboard as JWTs. To get a token your API can verify, request it with your API's identifier as the audience.

The 2022 question on this, Auth0 with NextAuth (about 1,000 views, no answers), includes the asker's own guess that the token is an encrypted JWE. Whichever of the two it was, jsonwebtoken rejects the value before it uses any key: with jwt malformed when it does not split into three parts, and with invalid token when it does.

To see what you have, decode only the first part and print the names in it, not the values. The header of a JWE is not encrypted, and RFC 7516 says a header with an enc member belongs to a JWE. Run this only after the shape log in the next section shows a value that starts with eyJ; if it could be a secret or an API key, the shape alone tells you enough:

Look at the first part only, names without values

const first = String(token).split('.')[0];
try {
  const json = JSON.parse(Buffer.from(first, 'base64url').toString('utf8'));
  console.log(Object.keys(json)); // member names only
} catch {
  console.log('first part is not JSON');
}
// has "enc"               → a JWE: decrypt it with the library that issued it
// has "alg", no "enc"     → a JWS header: periods were lost or added on the way
// claims such as "sub"    → the payload alone (see 03)
// not JSON                → not a JWT: a key, an opaque token or plain text

The 'base64url' encoding in Buffer exists since Node 14.18 and 15.7.

Find out what arrived, without logging the token

A bearer token is a credential, and RFC 6750 says clients must make sure bearer tokens are not leaked to unintended parties. A log line is one more place a token can end up. You do not need the whole value for this error. Log its shape once, right before jwt.verify(), and delete the line afterwards:

Temporary: the shape, not the token

console.log({
  type: typeof token,
  length: typeof token === 'string' ? token.length : null,
  parts: typeof token === 'string' ? token.split('.').length : null,
  start: String(token).slice(0, 6), // enough to recognize null, Bearer or eyJ
});

If the value might be a secret or an API key, leave out start; the other three fields narrow it down on their own. Then read the result like this:

  • type: 'undefined', length: 0, or type: 'object' with start: 'null' (the value null): nothing arrived, and the message is jwt must be provided. Check the header name and whether the client sends it at all.
  • type: 'object' with anything else: jwt must be a string. You passed the object that holds the token, not the token.
  • parts: 1 and start is null, undefi or Bearer: section 02 or 03.
  • parts: 1 and start is eyJ: one piece of a token, usually the payload (03). A JSON object that begins with {" and a letter encodes to text starting with eyJ, which is why the example tokens in RFC 7515 start that way.
  • parts: 1 and anything else, such as sb_pub: a key or a placeholder (01).
  • parts: 5 and eyJ: a JWE (04).
  • parts: 3: the count passes, so this error is gone, and whatever you see now comes from a later check.

Confirm the fix

Send the request again through the same client code, not with a token pasted into Postman, because the bug usually lives in the code that builds the header. The count is the first check that looks inside the string, so after a fix the message either disappears or moves to a later check:

  • The payload comes back. Done.
  • invalid token. Three parts now, but the value does not decode: usually a Bearer prefix, quotes or whitespace still in it, which the base64url check in the jws package rejects. A header that decodes with a payload that is not JSON can throw a SyntaxError instead.
  • invalid signature. The token is well formed now, and its signature does not match your key. That is a different problem, covered in the note on invalid signature.
  • jwt expired. Well formed and correctly signed, just old. Sign in again.

Then test the empty case on purpose: call the endpoint without an Authorization header, and load the client once while signed out. Both requests should get your 401 for a missing token without ever reaching jwt.verify().

Traps

  • Changing the secret. The key is not used until after the count, so rotating or re-pasting JWT_SECRET cannot fix jwt malformed. It can only add a second problem.
  • Decoding instead of verifying. jwt.decode() never checks the signature, and the jsonwebtoken README says not to use it for untrusted messages. It also returns null for anything that is not a three-part signed token, JWEs included, so a null from it is a hint about the shape and nothing more.
  • Switching off NextAuth's encryption so another service can read the cookie. NextAuth recommends keeping the default JWE. Read the session with getToken() on the Next.js side, and give the other service a token meant for it, such as an access token issued for its audience.
  • Answering with a 500. RFC 6750 puts a malformed token under invalid_token and says the resource server should respond with 401, after which the client may get a new token and retry. A 500 tells the client nothing it can act on.
  • Logging whole tokens while you debug. Log the shape as above. If a real token did end up in a log, treat it as exposed until it expires.

Check a value in your browser

If you have the exact value your server received, paste it into JWT Doctor. It strips a Bearer prefix, wrapping quotes and whitespace, and tells you which of them it found, counts the parts and periods, and decodes the header and payload. Give it a secret or a public key and it also verifies the signature. It runs in your browser, and the page states that nothing you paste leaves it. For the shape alone you do not need a real token: a test token with the same problem gives the same count.

Questions

Does jwt malformed mean my secret is wrong?

No. jsonwebtoken counts the parts before it reads the header or uses a key, so your secret has not been involved yet. A wrong secret shows up later, as invalid signature, once the value really is a token.

The token works in a JWT debugger. Why does my server say jwt malformed?

The debugger checked the string you pasted, and your server received a different one. The usual differences: the text null or undefined from a client that had no token yet, the word Bearer or the payload alone from splitting the header, or an encrypted session cookie instead of the token you copied. Log the shape of the value on the server and compare.

Why does null give jwt must be provided, but "null" gives jwt malformed?

jsonwebtoken rejects empty values first: undefined, null and an empty string all stop at jwt must be provided. The four-letter string "null" is not empty, so it reaches the count, has no period, and fails as jwt malformed. A template literal such as `Bearer ${token}` turns a null token into exactly that string.

Can jsonwebtoken verify an encrypted JWT (JWE)?

Not as it is. jwt.verify() accepts only the three-part signed form, and a JWE has five parts, so it fails as jwt malformed. Decrypt it with the library or service that issued it, NextAuth's getToken() for example, or ask that issuer for a signed token meant for your API.

What status code should my API return for a malformed token?

401. RFC 6750 lists a malformed token under the invalid_token error and says the resource server should respond with 401 Unauthorized and a WWW-Authenticate: Bearer header. When no token was sent at all, it says to leave the error code out.

Sources