Guide
Base64, Base64URL, and the padding problem
Base64 shows up in data: URIs, email attachments, JWTs, API keys, and config files — and in about half of those it is quietly a different Base64 than the one you learned. The difference is two characters and a bit of padding, which is exactly small enough to go unnoticed until something fails to decode.
What Base64 is for
Plenty of systems were designed to carry text and only text: email headers, HTTP headers, JSON string values, XML attributes, URLs, source files. Hand them arbitrary binary — a PNG, a compressed blob, an encryption key — and something in the chain will mangle it. A byte that happens to equal 0x0A becomes a line break. A byte above 0x7F gets reinterpreted through whatever character encoding the next hop assumes. A null byte truncates the value.
Base64 sidesteps all of that by re-expressing arbitrary bytes using only 64 characters that survive every text channel in common use. It takes the input three bytes at a time — 24 bits — and re-splits those 24 bits into four 6-bit groups. Each 6-bit group indexes into a 64-character alphabet. Three bytes in, four characters out.
That ratio is the whole cost model: Base64 output is always about 4/3 the size of the input, so roughly 33% larger, before any padding or line breaks. It is not compression and it never has been — it trades size for the ability to travel through text-only pipes.
The alphabet, and the two characters that cause trouble
The standard alphabet, defined in RFC 4648 §4, is the 26 uppercase letters, the 26 lowercase letters, the ten digits, and then + and /. Sixty-four characters, indexes 0 through 63.
Fifty-two letters and ten digits are uncontroversial. The last two are the problem, because both already mean something in the places Base64 output most often ends up:
/is the path separator in a URL and in most filesystems. Base64 that lands in a path segment or a filename gets silently split into two.+means a literal space when a query string is parsed asapplication/x-www-form-urlencoded— which is what most server frameworks do by default. A+in your Base64 arrives at the server as a space, and the decode fails with no obvious cause.
So RFC 4648 §5 defines a second alphabet, usually called Base64URL. It is identical except that index 62 is - instead of +, and index 63 is _ instead of /. Same data, same bit packing, two characters swapped.
The two alphabets are not detectable from the data alone. A string containing neither +, /, -, nor _ is valid in both and decodes identically. Whether a given decoder needs one or the other is a fact about the format you are reading, not about the string in front of you.
Padding, and why some formats drop it
Input does not always divide evenly into three-byte groups. When one or two bytes are left over at the end, the encoder still emits a whole four-character group, and pads the unused positions with =:
1 byte in -> 2 characters + "==" e.g. "f" -> "Zg=="
2 bytes in -> 3 characters + "=" e.g. "fo" -> "Zm8="
3 bytes in -> 4 characters, no padding e.g. "foo" -> "Zm9v"
The padding carries no data. It exists so that a decoder reading a concatenated stream knows where one encoded value ends, and so that the total length is always a multiple of four. If you already know the length — because the value is a single field in a JSON object, or a single segment between dots — the padding is redundant.
That is why JWTs, and most other formats that use Base64URL, strip it. A JWT segment is delimited by ., so nothing is ambiguous without the =, and dropping it saves a couple of characters per segment in a token that travels on every request.
Re-adding padding is mechanical. Take the string length modulo 4:
- remainder 0 — nothing to add.
- remainder 2 — append
==. - remainder 3 — append
=. - remainder 1 — the string is malformed. There is no valid Base64 whose length is 1 more than a multiple of 4.
Converting between the two
Going from standard to URL-safe is a character swap plus dropping padding. Going back is the swap in reverse plus re-padding by the rule above.
// standard -> URL-safe
b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// URL-safe -> standard
const std = b64url.replace(/-/g, '+').replace(/_/g, '/');
std + '='.repeat((4 - (std.length % 4)) % 4);
Most standard libraries can do it directly and you should prefer that where it exists. Node has had a base64url encoding since v14: Buffer.from(input).toString('base64url') and Buffer.from(token, 'base64url'). Python has base64.urlsafe_b64encode and urlsafe_b64decode, though both still emit and expect padding. Go has base64.RawURLEncoding for the unpadded URL-safe variant and base64.URLEncoding for the padded one.
In the browser, the classic pair is btoa and atob, and they have a sharp edge worth knowing: they operate on strings whose characters are all in the range 0–255, not on Unicode text. btoa('é') throws. To encode real text you have to go through bytes first:
const bytes = new TextEncoder().encode(text); // UTF-8 bytes
const b64 = btoa(String.fromCharCode(...bytes)); // then Base64
// and back
const back = new TextDecoder().decode(
Uint8Array.from(atob(b64), c => c.charCodeAt(0))
);
Newer runtimes expose Uint8Array.prototype.toBase64() and Uint8Array.fromBase64(), which take an options object with an alphabet of 'base64' or 'base64url' — worth checking for before hand-rolling the swap.
Where you will run into each variant
Standard Base64, with +, /, and padding:
data:URIs in CSS and HTML —data:image/png;base64,iVBORw0…- MIME email attachments, where RFC 2045 additionally wraps the output at 76 characters with CRLF line breaks that a strict decoder may reject if you forget to strip them
- HTTP Basic authentication headers
- PEM-armoured keys and certificates, wrapped at 64 characters between
-----BEGIN-----markers - Binary blobs embedded in JSON, XML, or YAML
Base64URL, with -, _, and usually no padding:
- All three segments of a JWT, and the JOSE family generally (JWS, JWE, JWK)
- WebAuthn credential IDs and challenges
- OAuth 2.0 PKCE code challenges
- Anything designed to sit in a URL path or query without further escaping
If you have standard Base64 and need to put it in a URL, you have two options: convert it to Base64URL, or percent-encode it so + becomes %2B and / becomes %2F. Both work. Converting is smaller and does not have to survive a second round of decoding, which is why formats designed for URLs chose it.
Three things Base64 is not
It is not encryption. There is no key. Anyone with the string can decode it instantly, and every language ships a decoder. Base64 in a config file protects a credential from being read over someone's shoulder and from nothing else.
It is not compression. It makes data one third larger. Base64-encoding something already compressed is normal and fine; expecting the Base64 step to help with size is not.
It is not a checksum or an integrity mechanism. Flipping a character in a Base64 string usually produces another valid Base64 string that decodes to different bytes. If you need to know the data arrived intact, that is a hash or a MAC, separately.
One thing it genuinely does give you: a string safe to log, paste into a ticket, or diff — which is why it survives despite the 33% overhead.
Decoding problems, and what usually causes them
- "Invalid character" errors — you are feeding URL-safe input to a standard decoder, or the string picked up whitespace or CRLF line breaks from MIME/PEM wrapping. Strip whitespace first.
- "Invalid length" errors — padding was stripped and the decoder is strict. Re-pad with the modulo-4 rule.
- Spaces where
+should be — the string went through a query string and got form-decoded. Convert to Base64URL, or percent-encode before it goes into the URL. - Decodes but produces mojibake — the bytes are fine and the text encoding is wrong. Base64 is byte-level; whether those bytes are UTF-8, Windows-1252, or a PNG is a separate question the encoding does not record.
That last one is worth dwelling on, because it is the one that looks like a Base64 bug and never is. Base64 round-trips bytes perfectly. If the text comes out wrong, the mismatch is between the character encoding used to turn text into bytes before encoding, and the one used to turn bytes back into text after decoding.
Last updated 10 August 2026