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.
By the Withuse team · Updated
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 paddingSwapping 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.