Your Node backend calls jwt.verify() from the jsonwebtoken package, and instead of a payload it throws this:
JsonWebTokenError: jwt malformedThe 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.
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 samesecretas your NextAuth configuration. Forward the raw cookie to another backend that runsjwt.verify(), and that backend sees five parts. - Auth0 access tokens without an audience. Auth0's documentation lists the access token for its
/userinfoendpoint, requested with noaudience, 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 theaudience.
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, ortype: 'object'withstart: '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: 1andstartisnull,undefiorBearer: section 02 or 03.parts: 1andstartiseyJ: one piece of a token, usually the payload (03). A JSON object that begins with{"and a letter encodes to text starting witheyJ, which is why the example tokens in RFC 7515 start that way.parts: 1and anything else, such assb_pub: a key or a placeholder (01).parts: 5andeyJ: 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 aBearerprefix, 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 aSyntaxErrorinstead.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_SECRETcannot 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 returnsnullfor anything that is not a three-part signed token, JWEs included, so anullfrom 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_tokenand 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
- auth0/node-jsonwebtoken, README, "Errors & Codes" ('jwt malformed': the token does not have three components delimited by a period; 'invalid token': the header or payload could not be parsed): github.com/auth0/node-jsonwebtoken#errors--codes; the same README on
jwt.decode(does not verify the signature, not for untrusted messages): github.com/auth0/node-jsonwebtoken#jwtdecodetoken--options - auth0/node-jsonwebtoken,
verify.jsin 9.0.3 (the checks in the order shown: jwt must be provided, jwt must be a string, the split at periods with jwt malformed, then decoding, where an error thrown by the decoder is passed on as it is and a failed decode gives invalid token, and only then the secret or key):github.com/auth0/node-jsonwebtoken/blob/master/verify.js; the same checks in 8.5.1: github.com/auth0/node-jsonwebtoken/blob/v8.5.1/verify.js;decode.js(returns null when jws cannot decode the value): github.com/auth0/node-jsonwebtoken/blob/master/decode.js - auth0/node-jws,
verify-stream.js(JWS_REGEXallows only base64url characters and exactly two periods; decode returns null for anything else, and for a header withtypJWT it runsJSON.parseon the payload, which throws when the payload is not JSON):github.com/auth0/node-jws/blob/master/lib/verify-stream.js - panva/jose,
jws_verify.ts(JWSInvalid('Invalid Compact JWS')when a compact JWS does not split into three parts): github.com/panva/jose/blob/main/src/lib/jws_verify.ts; jpadilla/pyjwt,api_jws.py(DecodeError("Not enough segments")): github.com/jpadilla/pyjwt/blob/master/jwt/api_jws.py - RFC 7519, JSON Web Token (JWTs are always represented in the JWS or the JWE Compact Serialization, and the number of parts depends on which): rfc-editor.org/rfc/rfc7519
- RFC 7515, JSON Web Signature, section 7.1 "JWS Compact Serialization": rfc-editor.org/rfc/rfc7515#section-7.1; section 5.2 ("exactly two delimiting period characters"): rfc-editor.org/rfc/rfc7515#section-5.2; section 3.3 "Example JWS" (encoded headers and payloads starting with eyJ): rfc-editor.org/rfc/rfc7515#section-3.3
- RFC 7516, JSON Web Encryption, section 7.1 "JWE Compact Serialization" (five parts): rfc-editor.org/rfc/rfc7516#section-7.1; section 9 (three segments for a JWS, five for a JWE; an
encmember means a JWE): rfc-editor.org/rfc/rfc7516#section-9 - RFC 6750, Bearer Token Usage, section 2.1 (the Authorization header syntax, "Bearer" and one or more spaces): rfc-editor.org/rfc/rfc6750#section-2.1; section 3 and 3.1 (WWW-Authenticate,
invalid_tokenfor a malformed token with 401, no error code when the request has no authentication): rfc-editor.org/rfc/rfc6750#section-3.1; section 5.3 ("Safeguard bearer tokens"): rfc-editor.org/rfc/rfc6750#section-5.3 - RFC 9110, HTTP Semantics, section 11.1 (the authentication scheme is a case-insensitive token): rfc-editor.org/rfc/rfc9110#section-11.1
- Node.js, HTTP,
message.headers("Header names are lower-cased."): nodejs.org/api/http.html#messageheaders; Buffer, character encodings ('base64url', added in v15.7.0 and v14.18.0): nodejs.org/api/buffer.html#buffers-and-character-encodings - MDN,
String, "String coercion" (undefined becomes "undefined", null becomes "null", and a template literal does the same): developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String#string_coercion;Storage.getItem()(null when the key does not exist): developer.mozilla.org/en-US/docs/Web/API/Storage/getItem;Storage.setItem()("Storage only supports storing and retrieving strings"): developer.mozilla.org/en-US/docs/Web/API/Storage/setItem;typeof(typeof nullis "object"): developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/typeof - WHATWG HTML Living Standard, "The Storage interface" (
setItem(DOMString key, DOMString value): the value is taken as a string, so undefined arrives as "undefined"): html.spec.whatwg.org/multipage/webstorage.html#the-storage-interface - NextAuth.js, "Options" (JSON Web Tokens are encrypted (JWE) by default, and the docs recommend keeping it): next-auth.js.org/configuration/options#jwt; "Securing pages and API routes" (
getToken()handles decryption and verification, with the same secret): next-auth.js.org/tutorials/securing-pages-and-api-routes - Auth0, "Access Tokens" (opaque and JWT access tokens; tokens for a custom API follow the JWT standard): auth0.com/docs/secure/tokens/access-tokens; "Get Access Tokens" (the /userinfo endpoint with no audience gives an opaque token, a custom API identifier as the audience gives a JWT): auth0.com/docs/secure/tokens/access-tokens/get-access-tokens
- Supabase, "API keys" (publishable and secret keys are not JWTs, go on the apikey header, not Authorization: Bearer; the legacy
anonandservice_rolekeys have the format "JWT (long-lived)" and are the legacy versions of the publishable and secret keys):supabase.com/docs/guides/api/api-keys; "JSON Web Token (JWT)" (the user's access token is a JWT, sent as Authorization: Bearer): supabase.com/docs/guides/auth/jwts - Stack Overflow, "Json Web Token verify() return jwt malformed" (2018, about 171,000 views; the secret sent as the token, the accepted answer with Authorization: Bearer; later answers on a null token, a Postman environment variable, underscores instead of periods, and
replace("Bearer ", "")): stackoverflow.com/questions/51849010 - Stack Overflow, "JWT not decoding "JWT malformed" - Node Angular" (2016, about 35,000 views;
split('.')[1]kept only the payload, the accepted answer splits at the space): stackoverflow.com/questions/35375215 - Stack Overflow, "Auth0 & Next-Auth Malformed JWT" (2022, about 1,000 views, no answers; the asker suspects an encrypted JWE): stackoverflow.com/questions/74258155