Web Toolkit

Base64, percent-encoding and which one you need

Base64 makes binary survive a text channel. Percent-encoding makes text survive a URL. Swapping them corrupts data quietly.

Both encodings turn one string into a longer, uglier string, which is where the confusion starts. They exist for different reasons, and using one where the other belongs produces data that looks fine until something downstream reads it.

Base64 exists because some channels only carry text. It maps arbitrary bytes onto 64 printable ASCII characters so binary can travel through an email body, a JSON string or an XML document without being mangled.

Percent-encoding — usually called URL encoding — exists because some characters have a job inside a URL. ?, &, =, / and # are structure, so a value that contains one has to be escaped or it changes the meaning of the address.

Neither is encryption, neither is compression, and neither provides any integrity guarantee. Base64 in a basic-auth header is not a security measure; it is a transport convention that anyone can reverse in one step.

Percent-encoding: which characters, and which function

RFC 3986 defines a small set of unreserved characters that never need escaping — A-Z a-z 0-9 - . _ ~ — and a set of reserved characters that carry meaning in a URL. Everything else becomes % plus two hex digits per UTF-8 byte, which is why a single non-ASCII character usually turns into two or three escapes: é is %C3%A9, not %E9.

The choice that actually bites is which JavaScript function to use:

  • encodeURIComponent escapes the reserved characters. Use it for a single value — a query parameter, a path segment, a fragment.
  • encodeURI deliberately leaves /, ?, &, = and # alone, because it is meant for an entire URL that already has structure.

Passing a value through encodeURI is the standard way to produce a URL that works in testing and breaks the first time a user's input contains an ampersand. If you are encoding a part, use the component function.

Neither escapes !, ', (, ) or *, which some servers treat as delimiters. And + is the one to watch: it is a literal plus in a path but means space in a form-encoded query string. So ?q=1+1 is usually read as "1 1", and a base64 payload containing + becomes corrupt the moment it is used as a query value unescaped. Encode it as %2B, or use base64url.

Base64: the shape of the output

Base64 takes three bytes at a time and emits four characters, so output is always about 133% of the input size — a 1 MB image becomes roughly 1.37 MB. When the input length is not a multiple of three, the encoder pads with = to keep the four-character grouping. That padding is why encoded strings so often end in = or ==.

The standard alphabet ends with + and /, both hostile inside a URL. Hence base64url (RFC 4648 §5): the same encoding with - and _ substituted, and the padding usually dropped. This is what JSON Web Tokens use, and it is why a JWT can be pasted into a URL but a standard-base64 blob cannot.

Decoders differ in strictness. Some accept both alphabets and missing padding; others reject anything non-canonical. If a payload decodes in one language and fails in another, the alphabet or the padding is nearly always the difference.

The UTF-8 step people skip

Base64 encodes bytes, not characters, so any text has to become bytes first — and that step needs an explicit encoding.

In browsers, btoa throws on any character above U+00FF, which means it fails on accented Latin, on CJK and on every emoji. The fix is to run the string through TextEncoder (or a UTF-8 buffer in Node) and encode the resulting bytes. Implementations that instead strip or mask the high bits produce output that decodes to mojibake — silently, and only for some users.

Coming back the other way, decode to bytes and then interpret those bytes as UTF-8. Skipping the second half is how "café" becomes "café".

Double-encoding, and how to recognise it

If you see %2520 in a log, that is %20 encoded a second time — the % became %25. It happens when a value is encoded by application code and then again by a framework, an HTTP client or a redirect handler.

The reverse is worse. Decoding twice on the server means a value containing %252e%252e%252f arrives as ../ after the second pass, which is the classic path-traversal bypass. Decode exactly once, at the boundary where the data enters, and treat what comes out as untrusted content rather than as structure.

A quick sanity check for any encoded value: round-trip it. Decode, compare against the original, and if you had to decode twice to get something readable, something upstream is encoding twice.

Data URIs and QR codes: where size becomes the constraint

A data URI embeds a base64 payload directly in a document — data:image/png;base64,... — which removes an HTTP request. It is a reasonable trade for a small icon in a stylesheet. It is a bad trade for anything large: the 33% growth is real, data URIs are not cached separately from the document that contains them, and a big one blocks parsing of the file it lives in. Keep it to a few kilobytes.

QR codes make the size constraint physical. Capacity depends on the version and the error-correction level, and each extra byte adds modules to the grid, which makes them denser and harder to scan from a printed page or a phone camera. Two practical consequences: encode a short URL rather than a long one with tracking parameters attached, and remember that uppercase alphanumeric content uses a denser QR mode than mixed case does — hostnames are case-insensitive, so an all-caps URL fits in fewer modules than the same address in lower case.

The rule of thumb

Ask what the channel refuses to carry. If the answer is "bytes that are not text", that is base64. If the answer is "characters that mean something structural here", that is percent-encoding. If a value has to pass through both — a binary blob inside a query string — encode with base64url first, then treat the result as a normal value and percent-encode it only if the alphabet still contains something the URL cares about.

Tools used in this guide

All guides