The short answer
Base64 encodes arbitrary bytes into a safe alphabet of 64 characters so the data can travel through a text-only channel — an email body, a JSON string, an HTTP header, a data URL. It is not encryption and it does not compress; it makes binary data representable. Percent-encoding (URL encoding) escapes the characters that have a special meaning in a URL — or that are not allowed in one — so a value can be placed inside a URL component without being misparsed. It does not make arbitrary bytes safe; it makes a URL safe.
The practical test: if the output is going to be read by a parser that expects a URL component, use percent-encoding. If it is going into a field that must contain only printable ASCII and the receiver will decode it with a Base64 decoder, use Base64. Using Base64 inside a URL works, but you then need URL-safe Base64 (see below); using percent-encoding to carry binary breaks as soon as the data contains a byte that has no valid escape.
What each one actually leaves alone
The two alphabets overlap just enough to be confusing. This is the exact behaviour of the encoders
on this site, which is the standard behaviour of btoa/encodeURIComponent
in every browser.
| Character | Base64 | Percent-encoding |
|---|---|---|
A–Z a–z 0–9 | Kept as-is | Kept as-is |
- _ . ! ~ * ' ( ) | - and _ only in URL-safe mode | Kept as-is |
space | Encoded as part of the byte stream | %20 |
+ / = | The Base64 alphabet itself | Encoded: %2B %2F %3D |
& ? # / : | Encoded as data | Encoded: %26 %3F %23 %2F %3A |
" ' < > | Encoded as data | " → %22; ' kept |
The single most important row is the third. +, / and = are
letters of the Base64 alphabet — they carry data — while in a URL they are ordinary
characters that must be escaped. That single clash is behind most of the “my Base64 works in a
query string but not in production” bugs.
Worked example: the same three bytes, two encodings
Take the ASCII text a b&c=d. Base64 does not escape anything; it re-encodes the
whole byte sequence, so every character changes. Percent-encoding leaves most of the string alone
and changes only the four characters that mean something to a URL parser:
input a b&c=d
Base64 YSBiJmM9ZA==
percent-encoding a%20b%26c%3Dd
Notice that the Base64 output contains +, / and = in general
(this particular input happens not to), which is precisely why Base64 output cannot be dropped into a
URL unmodified. The percent-encoded output is longer than its input by design: three characters per
escaped byte.
The trap: Base64 inside a URL
A URL that carries + is ambiguous. In a query string, + has historically
meant “space” (form encoding, not percent-encoding), while in a path it is a literal plus. So a
token like aGk+YmFy can arrive at the server as aGk YmFy depending on who
decodes it. The fix is URL-safe Base64 (RFC 4648 §5): replace + with
-, / with _, and usually drop the = padding.
| Standard Base64 | URL-safe | Where you meet it |
|---|---|---|
+ | - | JWT segments, JWK keys |
/ | _ | OAuth state, opaque tokens |
= padding | usually stripped | Anything that must survive a copy/paste |
Padding is the other frequent surprise. Base64 works in groups of four characters; if the input
length is not a multiple of three bytes, the final group is padded with =. Many decoders
accept unpadded input, and many strict ones reject it. When a token “works in one service and not
another”, check padding before anything else.
What each one is not
- Base64 is not encryption. There is no key. Anyone can decode it in one step; it hides nothing and protects nothing. Treat a Base64 string as plaintext.
- Base64 is not compression. It grows the data by roughly 33% — every 3 bytes become 4 characters. It is a size penalty paid to make binary representable as text.
- Percent-encoding is not sanitisation. Escaping
<as%3Cprevents a URL from being mangled; it does nothing about HTML injection. That is a different escape for a different context — HTML entities — and using the wrong one is how “we already sanitised it” bugs happen. - Neither is a substitute for the other. Re-encoding a Base64 string with
encodeURIComponentdoes not make it URL-safe in the way its consumer expects; it just makes a different string that the consumer will not recognise.
Choosing, in one pass
- Binary in a text field (email attachment, JSON blob, data URL, PEM body) → Base64.
- Binary in a URL (token, filename, opaque id) → URL-safe Base64, then percent-encode it if the surrounding URL still needs it.
- A value inside a query parameter or path segment → percent-encode the value,
component by component. Do not encode the whole URL — that escapes the
?and&that separate the query from the path. - A value going into HTML → HTML-encode it. Escaping it as a URL or Base64 leaves
<script>intact where the browser will parse it.
The common production failure is not picking the wrong encoding on purpose; it is picking the right one for the first hop and forgetting that the value crosses a second boundary — Base64 into a URL, URL-encoded into an HTML attribute — and needs the second escape as well. Encodings compose; each boundary needs its own.
Padding: the detail that breaks interop
Base64 processes input in groups of three bytes, producing four characters. When the input length is
not a multiple of three, the final group is short and is padded with = so the output length
stays a multiple of four:
| Input | Bytes | Output | Padding |
|---|---|---|---|
a | 1 | YQ== | two = |
ab | 2 | YWI= | one = |
abc | 3 | YWJj | none |
abcd | 4 | YWJjZA== | two = |
This is the interop detail that causes the most support tickets. Some decoders require padding and reject unpadded input; others accept both. JWT strips it entirely (Base64URL without padding), while many APIs expect it. When a token “works in Postman and not in the service”, the first thing to check is whether one side stripped padding and the other did not.
The safe habit for an API boundary: accept both, emit padded — or match the exact specification you are implementing, and document it. Silently adding or removing padding in a decoder is how two services end up disagreeing about the same string.
Size: the 33% you pay
Base64 turns every 3 bytes into 4 characters, so the output is 4/3 the size of the input —
about 33% larger. Add line breaks for PEM or MIME and it is larger still. That overhead is the reason
Base64 is a poor choice for large payloads in a size-constrained channel: a 10 MB file becomes roughly
13.3 MB as Base64, which may exceed a limit the binary would have passed.
It is also why “compress then Base64” is the right order. Base64 output is high-entropy text and does not compress meaningfully; compressing after encoding gains almost nothing. Compress the bytes first, then encode.
Line breaks in the wild
MIME (email) and PEM (keys, certificates) wrap Base64 at 64 or 76 characters per line. A decoder that expects a single continuous string will fail on a PEM body, and a decoder that tolerates whitespace will read both. This is why pasting a private key and getting “invalid base64” usually means the decoder did not strip newlines — the data is fine.
-----BEGIN CERTIFICATE-----
MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJJ
RTESMBAGA1UEChMJQmFsdGltb3Jl...
-----END CERTIFICATE-----
Note also the header and footer lines. The base64 payload is only what sits between them; including the markers in the decode input is another common cause of “invalid base64” on otherwise valid keys.
Percent-encoding is component-scoped
The most common URL bug is encoding the wrong scope. Percent-encoding exists to protect a component
— a path segment, a query value — not a whole URL. If you encode the entire URL, you escape the
?, & and = that give it structure, and the server receives one
long path with no query at all.
wrong: encodeURIComponent("https://example.com/s?q=a b")
https%3A%2F%2Fexample.com%2Fs%3Fq%3Da%20b
right: "https://example.com/s?q=" + encodeURIComponent("a b")
https://example.com/s?q=a%20b
The mirror-image mistake is under-encoding: building a query by string concatenation and forgetting that
a value containing & or = will create a new parameter. A search for
a&b=c passed naively becomes two parameters, q=a and b=c.
When the two are used together
A common real pattern is Base64 inside a URL: a token, an opaque id, a signed payload. That needs both
escapes, in the right order — URL-safe Base64 first, then percent-encoding if the surrounding URL still
needs it. Doing it in the other order fails, because percent-encoding the standard Base64 output does not
change the + into something a Base64 decoder will accept back.
bytes → Base64URL (RFC 4648 §5) → optional percent-encoding → URL
Check it yourself
The Base64 encoder and the URL encoder on this site run the same functions that produced the examples above, so the table is reproducible. For HTML context, the HTML entity encoder covers the third escape.