Skip to main content

Why Your JWT Won't Decode: Segments, Base64URL, and Padding

Every one of these errors is raised before any signature is checked, and none of them names the actual defect. PyJWT calls a four-segment token a padding problem; Node reports four unrelated causes as jwt malformed. Read the token shape rather than the sentence.

The short answer

A JSON Web Token is three base64url segments joined by two dots. Before any signature is considered, a library has to split the token, decode each segment, and parse the result as JSON. jwt malformed, Not enough segments, Invalid payload padding and invalid token are all failures of that early work — none of them says anything about whether the token is genuine.

The fastest check is not to read the message. Count the dots. There must be exactly two. In the sixteen cases measured below, wrong segment count, a stray Bearer  prefix, URL-encoding, and truncation together account for most real-world failures — and every one of them is visible in the shape of the token.

The message names the check, not the problem

This is the part that costs people time. A library reports which of its internal steps failed, and that step is often several removes from the actual defect. Measured on the versions listed at the end, 4 of the sixteen cases produce a message that points somewhere other than the real cause:

What is actually wrongWhat you are told
Four segmentsDecodeError: Invalid payload padding
Bearer prefix left on the tokenDecodeError: Invalid header padding
Character outside the base64url alphabetDecodeError: Invalid payload padding
Segment bytes are not valid UTF-8DecodeError: Invalid payload string: 'utf-8' codec can't decode byte 0xff

A four-segment token reported as a padding error is the clearest example. PyJWT splits the signature off the end first, so the remaining text still contains a dot; that text is then not valid base64, and base64 decoding complains about padding. The token has no padding problem at all. The same mechanism turns a leftover Bearer  prefix into Invalid header padding.

Node's jsonwebtoken generalises in the other direction. It reports jwt malformed for four unrelated causes and invalid token for six more, so the message narrows the field barely at all. Its jwt.decode() is quieter still: for almost every malformed shape it returns null with no error whatsoever.

What the three segments are

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxIn0 . SflKxwRJSMeKKF2...
└──────────── header ─────────────┘   └── payload ──┘   └─── signature ───┘
        base64url JSON                 base64url JSON      base64url bytes

Two dots. Three segments. The dots are separators, not part of any segment.

The header and payload are JSON objects, base64url-encoded. The signature is raw bytes over the first two segments. Only the signature depends on a secret — the header and payload are encoded, not encrypted, and anyone holding the token can read them.

Two segments, or four

Not enough segments and most instances of jwt malformed mean the split did not produce three parts. Two segments usually means the signature was dropped: a copy that stopped at a line break, a logging call that trimmed the value, or an unsigned token from a library that emitted only header and payload.

Four segments is the more confusing direction. It normally means a token was concatenated with something else, or that a five-segment JWE — encrypted rather than signed — is being handed to a JWS decoder. Remember that PyJWT reports this as a padding error rather than a segment-count error.

# The single most useful check, before reading any error message:
token.count(".")        # must be exactly 2
len(token.split("."))   # must be exactly 3

The Bearer prefix travelling with the token

An Authorization header holds an authentication scheme and a credential separated by a space. Passing the whole header value to a decoder puts Bearer  inside the header segment, where the space and the capital letters are not valid base64url.

Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc

# Wrong — the scheme travels with the token:
token = request.headers["Authorization"]

# Right — take only what follows the space:
token = request.headers["Authorization"].removeprefix("Bearer ")

The dot count is still two, so this does not present as a structural error. PyJWT calls it Invalid header padding, Node calls it invalid token, and neither mentions the prefix. The same thing happens with quotes left on a value read from a JSON body, and with a trailing newline from $(cat token.txt) in a shell.

Tokens damaged in transport

If the dots arrive as %2E, something URL-encoded the token — usually a query string built with an encoder that did not know the value was already URL-safe. The split then finds no dots at all and you get Not enough segments.

Truncation produces the same error from a different cause. JWTs are long, and they get cut by things with limits: a VARCHAR(255)column, the roughly 4 KB budget for a cookie, or a proxy header cap. The tell is a token that ends part-way through a segment. Comparing its length against the token at the source settles it in seconds, and no amount of decoding will recover the missing characters.

Base64URL, standard Base64, and padding

JWT segments use base64url, which differs from standard Base64 in three ways:

Standard Base64   A-Z a-z 0-9  +  /   with = padding
Base64URL         A-Z a-z 0-9  -  _   padding stripped

RFC 7515 specifies base64url with padding removed, because + / and =
all need escaping in URLs, form bodies, and cookies.

Genuine alphabet problems are rarer than the search volume suggests, because the token was produced by a library that got it right. When they do appear it is because something in the middle re-encoded the value — a debugging round-trip through a standard Base64 helper, or a system that stored the token after decoding and re-encoding it.

Implementations disagree markedly about how strict to be here, which is worth knowing before you conclude your token is broken. Measured: PyJWT accepts + and /, accepts correct = padding, and accepts even excess padding. Node rejects all three. Our decoder accepts the standard alphabet and correct padding but rejects excess padding. A token one library reads happily may be refused by the next.

It decoded, but it is not JSON — or not UTF-8

Past the base64url stage, two failures remain. A segment whose bytes are not JSON means the token was assembled by hand or corrupted after signing. A segment whose bytes are not valid UTF-8 means the same, and is worth calling out because the three implementations handle it differently: PyJWT raises a codec error, our decoder reports JWT segment is not valid UTF-8, and Node quietly substitutes a replacement character and reports success.

That last behaviour is the one to watch for. A silent substitution means a claim you read on screen is not the claim in the token, with nothing to indicate it. Both failures point at the producer; there is nothing to repair at the reader.

Empty segments, including an empty signature

An empty header or payload — ..sig or header..sig — fails everywhere, though the message names decoding rather than emptiness.

An empty signature is different, and worth being explicit about. A token ending in a bare dot still has three segments, so all three implementations decode its header and payload without complaint, ours included. Node only objects at verification time, with jwt signature is required. A decoder reading such a token successfully is not evidence that the token is acceptable — it is evidence that decoding and verification are separate operations.

Every case, measured

One fixture per row, run through all three implementations. The Node column is the verify message, because decode returns null without explanation for most of these:

Token shapePyJWT 2.13.0jsonwebtoken 9.0.3DataToolsHQ
Well-formed tokenDecodesDecodesDecodes
Only two segmentsDecodeError: Not enough segmentsJsonWebTokenError: jwt malformedInvalid JWT: expected header.payload.signature.
Four segmentsDecodeError: Invalid payload paddingJsonWebTokenError: jwt malformedInvalid JWT: expected header.payload.signature.
Bearer prefix left on the tokenDecodeError: Invalid header paddingJsonWebTokenError: invalid tokenInvalid JWT header: JWT segment is not valid base64url.
Dots URL-encoded as %2EDecodeError: Not enough segmentsJsonWebTokenError: jwt malformedInvalid JWT: expected header.payload.signature.
Cut short in transportDecodeError: Not enough segmentsJsonWebTokenError: jwt malformedInvalid JWT: expected header.payload.signature.
Three segments, empty signatureDecodesDecodes; verify: jwt signature is requiredDecodes
Empty payload segmentDecodeError: Invalid payload string: Expecting valueJsonWebTokenError: invalid tokenInvalid JWT payload: JWT segment is empty.
Empty header segmentDecodeError: Invalid header string: Expecting valueJsonWebTokenError: invalid tokenInvalid JWT header: JWT segment is empty.
Character outside the base64url alphabetDecodeError: Invalid payload paddingJsonWebTokenError: invalid tokenInvalid JWT payload: JWT segment is not valid base64url.
Standard Base64 alphabet (+ and /)DecodesJsonWebTokenError: invalid tokenDecodes
Correct = padding left onDecodesJsonWebTokenError: invalid tokenDecodes
Too much = paddingDecodesJsonWebTokenError: invalid tokenInvalid JWT payload: JWT segment is not valid base64url.
Segment decodes, but is not JSONDecodeError: Invalid payload string: Expecting valueSyntaxError: Unexpected token 'o'Invalid JWT payload: segment is not valid JSON.
Segment bytes are not valid UTF-8DecodeError: Invalid payload string: 'utf-8' codec can't decode byte 0xffDecodes, with the bad byte replaced by �Invalid JWT payload: JWT segment is not valid UTF-8.
Non-ASCII claimsDecodesDecodesDecodes

Working through it

  1. 1. Count the dots before anything elseTwo dots, three segments. Anything else is a structural problem and no encoding fix will help. This one check separates the largest group of causes from all the others, and it is faster than reading the error.
  2. 2. Look at what is attached to the tokenA Bearer prefix, surrounding quotes from a JSON body, or a trailing newline from a shell variable all land inside the header segment. The count is still three, so the failure surfaces as an encoding error rather than a structural one.
  3. 3. Check whether it survived transportDots arriving as %2E mean something URL-encoded the token. A token that is short and ends mid-segment was truncated — a column width, a cookie limit, or a proxy header cap are the usual culprits. Compare the length against the token at the source.
  4. 4. Then, and only then, consider the encodingGenuine base64url problems are the smallest category, because the token was produced by a library that got it right. When they do occur it is usually because something re-encoded the token in standard Base64, leaving + and / where - and _ belong.
  5. 5. A segment that decodes but will not parse is a producer bugIf the bytes come back but are not JSON, or are not valid UTF-8, the token was assembled by hand or corrupted after signing. Fix it at the producer; there is nothing to repair at the reader.

Pasting the token into the JWT Decoder does several of these steps at once. It names which segment failed and at which stage — structure, base64url, UTF-8, or JSON — which is the distinction the library messages above blur. Decoding runs in your browser, so a token is not uploaded anywhere.

Decoding is not verification

Everything on this page is about reading a token. None of it establishes that a token is genuine. The decoder does not check the signature, and a token that decodes cleanly may still be forged, expired, issued by the wrong party, or intended for a different audience.

The two operations answer different questions. Decoding answers what does this token say. Verification answers should I believe it, and requires the signing key and a library that checks the algorithm, the expiry, the issuer, and the audience. Never make an authorization decision from decoded claims alone — verify in your application, with your key.

The practical value of separating them is that the errors on this page all occur before verification can begin. If a token will not decode, no key and no configuration change will help until its shape is fixed.

Versions measured

Error wording is an implementation detail and changes between releases. The strings on this page were produced by:

  • PyJWT2.13.0 on Python 3.9.6
  • Node jsonwebtoken9.0.3 on Node.js 24.16.0
  • DataToolsHQ JWT Decoderbrowser-native base64url + UTF-8

The structure underneath does not change: two dots, three base64url segments, header and payload as JSON, in every version and every language.