Base64 in a query string: the plus sign that becomes a space

Standard Base64 uses + for the value 62, and a query string decodes an unescaped + as a space — then atob deletes the space instead of failing. Across 100,000 random 128-byte payloads, 78.1% broke loudly and 15.0% decoded into the wrong bytes with no error at all.

Every figure below was executed on Node 23.7.0. The decode path is the platform atob, not a library.

What the measurement shows

Three things have to line up for this to happen, and all three are ordinary. The payload has to be standard Base64 rather than the URL-safe variant. It has to travel in a query string without being percent-encoded first. And the receiving code has to hand the value straight to a decoder. None of those is a mistake anyone would flag in review.

We encoded 100,000 random payloads at each of five sizes and classified what happens when every + is replaced by a space, which is what the URL layer does:

payload   no plus sign   throws on decode   decodes, wrong bytes
 16 B         72.1%             27.8%                 0.0%
 24 B         60.2%             39.7%                 0.1%
 32 B         51.6%             48.0%                 0.4%
 64 B         26.4%             70.4%                 3.3%
128 B          6.9%             78.1%                15.0%

The middle column is the benign one. An exception is a bug report; you fix it the day it ships. The right-hand column is the problem, and it grows with payload size, because a longer string holds more plus signs and is correspondingly more likely to hold a multiple of four of them. At 128 bytes — a modest size for a signed token or an encrypted session blob — about one payload in seven corrupts without raising anything.

Why the space disappears instead of erroring

The natural assumption is that a space is an invalid Base64 character and the decoder will say so. It is not, and it will not. The HTML Standard specifies the algorithm behind atob as forgiving-base64 decode, and its very first step removes all ASCII whitespace from the input before any validation happens.

That rule exists for good reasons. Base64 arrives wrapped at 64 columns in PEM certificates and MIME headers, and a decoder that choked on those newlines would reject most of the Base64 in the world. The cost is that whitespace is not rejected, it is erased:

atob("YWJj")     -> "abc"
atob("YW Jj")    -> "abc"
atob("YW\nJj")   -> "abc"
atob("YW\tJj")   -> "abc"

So the character that was carrying six bits of your data does not become a complaint. It becomes nothing, and the bits after it shift left to fill the gap.

The case that throws, and the case that does not

With a single plus sign the arithmetic saves you. Encoding a?b>c~d produces YT9iPmN+ZA==; through a query string that becomes YT9iPmN ZA==, which is eleven characters once the space is stripped, and eleven is not a multiple of four. The decode throws and you notice.

With four, it does not. A 24-byte payload whose encoding happened to contain four plus signs:

sent      +JJW7+5tFQL2nvOb6Pegp+saKTdc+vDg      32 chars, four "+"
received   JJW7 5tFQL2nvOb6Pegp saKTdc vDg      28 chars after stripping

expected  f8 92 56 ef ee 6d 15 02 f6 9e f3 9b e8 f7 a0 a7 eb 1a 29 37 5c fa f0 e0
got       24 95 bb e6 d1 50 2f 69 ef 39 be 8f 7a 0a 6c 68 a4 dd 72 f0 e0

24 bytes in, 21 bytes out, no exception raised

Twenty-eight is a multiple of four, so nothing objects. The decoder returns twenty-one bytes that share no prefix with the original, and whatever consumes them next decides what the failure looks like. If those bytes were a ciphertext you get an authentication error three layers away from the cause. If they were a protobuf you get a parse error. If they were an opaque identifier you get a lookup miss, which is the worst of the three, because a lookup miss looks like ordinary absent data.

Three fixes, in order of preference

Use base64url. RFC 4648 section 5 defines the variant that substitutes - and _ for + and /, and usually drops the padding, for exactly this reason. It is why every segment of a JSON Web Token is base64url. We confirmed the round trip: -JJW7-5tFQI comes back from URLSearchParams byte-identical, where the standard form does not.

Percent-encode if the format is not yours to choose. When a system hands you standard Base64 and you only control the transport, encodeURIComponent turns + into %2B and = into %3D. We verified the value survives: +JJW7+5tFQI= becomes %2BJJW7%2B5tFQI%3D and parses back identical. Note that building the query by string concatenation is what skips this step — URLSearchParams percent-encodes for you.

Repair on receipt, knowing what it costs. value.replaceAll(" ", "+") restores the original exactly, and we verified that it does. But it is a guess about where the spaces came from, and it will corrupt any value that legitimately contained one. Use it to unblock a system you cannot change, not as the design.

How to tell which one you have

Look for + or / and for trailing =. Any of those means standard Base64 and means the value is unsafe to put in a URL unescaped. Their absence is not proof of base64url, because a short standard-form payload may simply not have drawn a 62 or a 63, which is exactly the 72.1% in the first row of the table above: safe today, and silently fragile the moment the payload grows.

Paste a value into the encoder and decoder on this site to see which variant it is — the URL-safe checkbox shows both forms of the same bytes side by side. If you want it next to the address bar instead, the same decoder is a Chrome extension: it normalises base64url and missing padding before decoding, runs offline, and declares no permissions, so a token you paste into it has no code path that could send it anywhere.

Frequently asked questions

Why does a plus sign in a URL turn into a space?

Because that substitution is part of the form-encoding format, not an accident. The application/x-www-form-urlencoded serialisation defined in the WHATWG URL Standard encodes a space as + and, on the way back, decodes every unescaped + as 0x20. A query string is parsed with that format by essentially every web framework, so a raw + you placed in a URL by hand comes out the other side as a space. Standard Base64 happens to use + for the value 62, which puts the two conventions in direct conflict. Nothing is broken on either side: the URL layer is doing exactly what its specification says, and so is the Base64 alphabet. The conflict only exists because the same character has a meaning in both, and the URL layer runs first.

Why does the decoder not raise an error about the space?

Because the HTML Standard tells it not to. The algorithm behind atob is called forgiving-base64 decode, and its first step is to remove all ASCII whitespace from the input before anything else is inspected. The intent is kind: Base64 arrives wrapped across lines in MIME headers and PEM files, and a decoder that rejected those newlines would be useless. The consequence is that the space your + turned into is not treated as an invalid character, it is deleted. We measured it directly: atob("YW Jj"), atob("YW\nJj") and atob("YW\tJj") all return "abc", identical to atob("YWJj"). So the character that carried six bits of your data simply stops existing, and the decoder carries on.

When is the corruption silent rather than an error?

When the number of plus signs removed is a multiple of four. Deleting characters shortens the string, and a Base64 decoder validates length modulo four. Drop one, two or three and the remainder no longer works out, so the decode throws and you find out immediately. Drop exactly four, or eight, or twelve, and the length is still a multiple of four, so the decoder accepts the shortened string and produces bytes — just not your bytes. In one measured case a 24-byte payload with four plus signs came back as 21 completely different bytes with no exception raised. The failure mode therefore gets worse as payloads get longer, because longer payloads contain more plus signs and are more likely to hit a multiple of four.

How likely is this in practice?

It depends almost entirely on payload size, and the numbers are higher than most people guess. We encoded 100,000 random payloads at each of five sizes and counted how many contained plus signs, then classified what would happen if each one were dropped. At 16 bytes, 72.1% contained no plus sign at all and were therefore safe, 27.8% would throw, and the silent case rounded to 0.0%. At 128 bytes only 6.9% were safe, 78.1% would throw, and 15.0% would decode into the wrong bytes with no error. That last figure is the one that matters: roughly one encrypted session blob or signed payload in seven, at that size, fails in a way that no exception handler will catch.

What is the right fix?

Use base64url, and percent-encode if you cannot. RFC 4648 section 5 defines a variant that substitutes - and _ for + and /, precisely so the output survives a URL untouched, and it is why JSON Web Tokens use it for all three segments. We confirmed a base64url value round-trips through URLSearchParams unchanged while the standard form does not. If you are stuck with standard Base64 from a system you do not control, percent-encode it on the way out — encodeURIComponent turns + into %2B, and we verified the value comes back identical. Repairing it on the receiving side with value.replaceAll(" ", "+") also works and we verified that too, but it guesses, and it will corrupt any payload that legitimately contained a space.

References

For the encode side of the same problem — why btoa throws on Korean and emoji but not on café — see Base64 in JavaScript. For the variant comparison on its own, see Base64 vs hex. More tools at withuse.io/tools.