JWT segments: the header decodes everywhere, the payload does not

The standard JWT header is 27 bytes — a multiple of three, so it encodes with no padding and draws none of the base64url substitutions. Every decoder we measured reads it. On the payload and signature of the same token, seven of eleven standard-library calls fail.

Measured on Node 23.7.0, Python 3.13.5, Go 1.22.0 and .NET 10.0.400, against one real HS256 token.

The token

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkppbiIsImlhdCI6MTUxNjIzOTAyMn0.6qWpbJoFSumAck6t5rly666opZNb_65YypXmzNAKzm0
segment     length   length % 4   contains - or _
header         36         0            no
payload        67         3            no
signature      43         3            yes  ("_")

Three segments, three different shapes. The header is clean on both counts. The payload is unpadded. The signature is unpadded and carries a substituted character. That is why one token can make a decoder look correct and then broken within the same line of code.

What eleven standard-library calls do

                                      header  payload  signature
Node    atob()                          OK      OK       FAIL
Node    Buffer.from(v, "base64url")     OK      OK       OK
Node    Buffer.from(v, "base64")        OK      OK       OK

Python  base64.urlsafe_b64decode        OK      FAIL     FAIL
Python  base64.b64decode                OK      FAIL     FAIL

Go      base64.RawURLEncoding           OK      OK       OK
Go      base64.URLEncoding              OK      FAIL     FAIL
Go      base64.RawStdEncoding           OK      OK       FAIL
Go      base64.StdEncoding              OK      FAIL     FAIL

.NET    Convert.FromBase64String        OK      FAIL     FAIL
.NET    Base64Url.DecodeFromChars       OK      OK       OK

The header column has no failures. Every other column does. Go is the clearest illustration because its four encodings separate the two variables cleanly: Raw means unpadded and URL means the substituted alphabet, and only the combination reads all three segments. Drop Raw and the padding kills you; drop URL and the signature does.

Node is the most forgiving by a wide margin — even its plain "base64" encoding accepts the URL alphabet and missing padding, which is convenient and is also why JavaScript developers are often surprised when the same token fails in a Python service. atob is the exception: it tolerates missing padding but rejects _ outright.

Why the header is the lucky one

Base64 turns three bytes into four characters, so an input whose length is a multiple of three encodes with no padding at all. The standard header lands exactly there:

{"alg":"HS256","typ":"JWT"}              27 bytes   27 % 3 = 0   no padding
{"typ":"JWT","alg":"HS256"}              27 bytes   27 % 3 = 0   no padding
{"alg":"RS256","typ":"JWT"}              27 bytes   27 % 3 = 0   no padding
{"alg":"HS512","typ":"JWT"}              27 bytes   27 % 3 = 0   no padding
{"alg":"RS256","typ":"JWT","kid":"abc123"}  42 bytes   42 % 3 = 0   no padding

{"alg":"none","typ":"JWT"}               26 bytes   26 % 3 = 2   needs padding

Swapping one five-character algorithm name for another does not change the length, and neither does reordering the two keys, so the whole family sits on the same multiple of three. Nothing in RFC 7519 requires this — it falls out of how short and how uniform real headers are. The unsigned alg: none header is one character shorter and behaves like a payload, which is a small irony given it is the header you least want to handle casually.

The signature never gets lucky

For HS256 the shape is fixed. An HMAC-SHA-256 output is always 32 bytes, and 32 is not a multiple of three, so the base64url form is always 43 characters with a remainder of three. We generated 200,000 signatures to check the other half:

HS256 signature, 200,000 samples
  base64url length     43  (always)
  length % 4            3  (always)
  contains - or _   73.6%

So roughly three signatures in four need the substituted alphabet, and all of them need the padding. Other algorithms are not all the same — HS384 produces 48 bytes, a multiple of three, and needs no padding:

HS256   32 bytes   43 chars   % 4 = 3   padding needed
HS384   48 bytes   64 chars   % 4 = 0   none
HS512   64 bytes   86 chars   % 4 = 2   padding needed
ES256   64 bytes   86 chars   % 4 = 2   padding needed
RS256  256 bytes  342 chars   % 4 = 2   padding needed

HS256 is both the most common choice and one of the two that always needs the fix, which is a large part of why this keeps being discovered.

The four lines that handle all three

Node    Buffer.from(seg, "base64url")
Go      base64.RawURLEncoding.DecodeString(seg)
.NET    Base64Url.DecodeFromChars(seg)
Python  base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4))

Python is the one with no single call that works, so the padding has to be restored by hand. -len(seg) % 4 is the idiom: it yields 0, 3, 2 or 1 for remainders 0, 1, 2 and 3, which is exactly how many = characters belong on the end. A remainder of one is invalid base64 outright and should be rejected rather than padded.

The decoder on this site does the substitution and the padding restore before it touches a base64 call, which is why it reads all three segments of a token you paste in — and it runs in the page, so a production token never leaves your machine.

Frequently asked questions

Why does my JWT decoder work on the header but fail on the payload?

Because the usual header is the one segment that hides both problems. JWT segments are base64url with the padding stripped, which means a decoder has to tolerate a length that is not a multiple of four and has to accept - and _ in place of + and /. The standard header serialises to 27 bytes, which is a multiple of three, so its encoding needs no padding in the first place, and 27 bytes of plain ASCII JSON almost never produce the two substituted characters. A strict standard-alphabet decoder therefore returns the header correctly and throws on the next segment along. You test with the first thing in the token, it works, and you ship.

Is the 27-byte header a coincidence?

No, it holds across the headers people actually send. We measured the common shapes: {"alg":"HS256","typ":"JWT"} is 27 bytes, and so are the RS256 and HS512 variants and the same two keys in the other order, because swapping one five-character algorithm name for another does not change the length. Adding a kid of six characters gives 42 bytes, also a multiple of three. The exception we found is {"alg":"none","typ":"JWT"} at 26 bytes, which does need padding — the unsigned token is the one case where the header behaves like the payload. So the pattern is not guaranteed by the specification, it is a consequence of how short and how uniform real headers are.

What does the signature segment do?

It fails on purpose, every time, for HS256. An HMAC-SHA-256 signature is always exactly 32 bytes, and 32 is not a multiple of three, so its base64url form is always 43 characters and always leaves a remainder of three when divided by four. There is no lucky case. On top of that the bytes are pseudorandom, so the substituted characters show up constantly: across 200,000 generated signatures, 73.6% contained a - or a _. Other algorithms differ — HS384 produces 48 bytes, a multiple of three, and needs no padding at all — but the one almost everyone starts with is the one that always needs both fixes. If you are writing a test for your decoder, generate a few hundred signatures rather than reusing one: a single sample has roughly a one in four chance of containing neither substituted character, which is enough to let a half-fixed decoder pass.

Which standard-library calls actually work?

Four of the eleven we measured handle all three segments. In Node, Buffer.from(value, "base64url") does, and so does the plain "base64" encoding because Node's decoder is lenient about both the alphabet and the padding. In Go it is base64.RawURLEncoding — Raw means unpadded and URL means the substituted alphabet, and you need both. In .NET 10 it is Base64Url.DecodeFromChars. Python has no single call that works: both urlsafe_b64decode and b64decode raise on the missing padding, so you restore it yourself with value + "=" * (-len(value) % 4) and then use the urlsafe variant. The split is not about which language is better written — Node and .NET both ship a dedicated base64url entry point and a stricter general one, and picking the wrong member of the pair is what fails. Go makes the two variables visible in the name, which is why its four encodings are the clearest way to see that padding and alphabet are separate decisions.

Should I decode the signature segment at all?

Only to verify it, and verification is a different operation from decoding. The signature is raw bytes rather than text, so turning it into a string is meaningless; what you do with it is recompute the HMAC over the first two segments and compare. The reason it belongs in this discussion is that a general-purpose JWT decoder written for a debugging tool will try to decode all three, and the third is where a strict base64 call fails. If your code only inspects claims, split on the dot and decode the second segment — and remember that reading claims from an unverified token tells you what the sender wrote, not what is true.

References

For what the claims inside the payload mean once you have decoded it, see JWT expiration; for why decoding is not verification, see how to verify a JWT. The same padding rule bites outside JWTs too — Base64 in a query string covers the case where characters go missing instead. More tools at withuse.io/tools.