Base64 vs base64url — the difference that breaks your JWT
You copy a JWT, paste its payload into atob(), and get an
InvalidCharacterError — or worse, silent garbage. The token is
valid. Your code looks right. The problem is two characters wide: somewhere
in that string there's a - or an _, and
atob() has never heard of them.
That's the entire difference between base64 and base64url — two swapped characters and some optional padding. Tiny. But it breaks things in exactly the confusing way that sends you down a two-hour rabbit hole. Here's the whole story in seven minutes.
- What base64 actually is — and why it exists at all
- The two-character difference, and why URLs forced it
- The padding rule everyone forgets
- Where you meet each variant day to day
- The four-line conversion that fixes
atob()on a JWT
What base64 actually is
Base64 is a way to represent binary data as plain text. It takes every 3 bytes (24 bits), splits them into 4 groups of 6 bits, and maps each 6-bit value (0–63) to one of 64 printable characters. Three bytes in, four characters out — a ~33% size increase, in exchange for text that survives any text-based system.
Why bother? Because a huge amount of infrastructure — email, HTTP headers, JSON, URLs, XML — is built for text, not arbitrary bytes. You can't safely shove a PNG or a cryptographic signature through those pipes raw. Base64 is the universal adapter.
Base64 hides nothing. It has no key, no secret, no protection — anyone who sees it can decode it in one step. This is exactly why a JWT is "signed, not encrypted": its payload is just base64url. See the JWT post for why that matters.
The alphabet — and the two characters that differ
Standard base64 (defined in RFC 4648) uses 64 characters: the letters, the
digits, plus + and /. Base64url is identical
except for the last two:
index: 0 ........... 25 26 ........... 51 52 ... 61 62 63
base64: A ........... Z a ........... z 0 ... 9 + /
base64url: A ........... Z a ........... z 0 ... 9 - _
62 of 64 characters are identical. Only positions 62 and 63 differ.
That's it. No other difference in the encoding itself. If a string contains
no +, /, -, or _, it's
literally the same in both — which is why this bug is intermittent and so
maddening. Tokens that happen to avoid those characters decode fine either
way; the one that contains a - is the one that breaks at 2am.
Why base64url exists
The standard alphabet's two special characters are both reserved in URLs:
+in a query string is interpreted as a space (a leftover from form encoding). So a+in your token silently becomes a space and corrupts it./is the path separator. A/in a token embedded in a URL can split it into a different path entirely.
So base64url swaps them for - and _ — both
"unreserved" characters that pass through URLs, filenames, and HTTP headers
untouched. Same encoding, URL-safe alphabet.
Standard base64 pads the end with = so the length is a
multiple of 4. Base64url usually drops the padding — and
JWTs always do. Decoders can infer the missing bytes from the
length, but a naive converter that forgets to re-add
= before calling a standard decoder will fail on exactly the
tokens whose length isn't divisible by 4.
Where you meet each one
- base64url: JWTs (all three segments), URL-safe session and CSRF tokens, Web Crypto output, filenames that must stay portable.
- base64: email attachments (MIME),
data:URIs (data:image/png;base64,…), binary embedded in JSON or XML,Authorization: Basic(which isbase64(user:pass)), and most hash-library output options.
The rule of thumb: if it's traveling inside a URL or a JWT, it's
base64url. If it's traveling inside a body, a header value, or an email,
it's probably standard base64. When in doubt, check for
-/_ (base64url) versus +//
and trailing = (base64).
The conversion — and the JWT fix
Converting between the two is a two-character swap plus padding handling.
This is the exact function that makes atob() work on a JWT:
// base64url → base64 (what you need before atob)
function b64urlToB64(s) {
s = s.replace(/-/g, "+").replace(/_/g, "/");
while (s.length % 4) s += "="; // re-add the dropped padding
return s;
}
// base64 → base64url
function b64ToB64url(s) {
return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
And here's the bug and the fix, side by side:
const payload = token.split(".")[1];
atob(payload); // ✗ throws on - or _ (or silently mis-decodes)
atob(b64urlToB64(payload)); // ✓ now it decodes — then JSON.parse
That's the whole trick the JWT decoder does under the hood — base64url to base64, then a UTF-8-aware decode so non-ASCII names and emails survive the trip. (The hash post covers the other half of that story, since digest output is where people first meet base64 and hex.)
The mistakes that cause the 2am bug
- Calling
atob()directly on a JWT segment. It expects standard base64; a-or_throwsInvalidCharacterError. Convert first. - Forgetting to re-add padding. The
while (s.length % 4)line isn't optional — skip it and you fail on exactly the tokens whose length isn't divisible by 4. - Putting standard base64 in a URL. The
+becomes a space in a query string, corrupting the token silently. Use base64url for anything that travels in a URL. - Assuming base64 means "protected." It's encoding, not encryption. Anything base64-encoded is readable by anyone who sees it.
- Mixing library outputs. One library emits base64, another expects base64url. Check the docs for each — the alphabet is the first thing to verify when two libraries disagree.
The full spec — both alphabets, the padding rules, and the test vectors — is RFC 4648, and it's genuinely short for an RFC.
Wrap-up
Base64 and base64url are the same encoding with a two-character alphabet
swap — + / becomes - _ — plus padding that
base64url usually drops. URLs force the swap because + and
/ mean something else there; JWTs adopt it for the same
reason. The fix for a JWT that won't decode is always the same four lines:
swap the characters, re-add the padding, then decode.
And the next time a token, a hash, or a data URI misbehaves, the first question to ask is always the same: which alphabet is this? Nine times out of ten, that's the whole bug.