A + in a query string is a space, not a plus
published
TL;DR
The same byte means two different things depending on which part of the URL it lands in.
const u = new URL('https://x.test/a+b?q=a+b');
u.pathname; // '/a+b' — literal plus
u.searchParams.get('q'); // 'a b' — space
Query strings are decoded as application/x-www-form-urlencoded, where + means space. Paths are decoded as generic URI syntax, where + means +. Percent-encode any + you meant literally — %2B — or use base64url instead of base64.
The problem
You put a base64 token in a query string. It comes back wrong. Nothing threw.
Here is the whole bug, on Node v26.2.0:
const token = '+vv8/f7/'; // valid standard base64
const got = new URLSearchParams('t=' + token).get('t');
got; // ' vv8/f7/' ← leading space
Buffer.from(token, 'base64').toString('hex'); // 'fafbfcfdfeff' (6 bytes)
Buffer.from(got, 'base64').toString('hex'); // 'beff3f7fbf' (5 bytes)
Six bytes went in, five different bytes came out. No exception at any step — atob does not throw either, because the forgiving-base64 algorithm strips whitespace and decodes whatever is left.
That is the failure mode: an HMAC that fails to verify one request in sixteen, a session token that is occasionally rejected, an encrypted blob that decrypts to noise. It only happens when the base64 output happens to contain a +, which is roughly a coin flip per token, so it reads as flakiness rather than as a bug.
The same split shows up wherever a value crosses from path to query:
Where the + is | Decoded as | Result |
|---|---|---|
/a+b (path) | RFC 3986 | /a+b |
?q=a+b (query) | form-urlencoded | a b |
#f+g (fragment) | RFC 3986 | #f+g |
So search?q=C%2B%2B finds C++ and search?q=C++ finds two spaces.
Why it happens
Two specs, both correct, disagreeing about one character.
RFC 3986 never assigns + a meaning. Section 2.2 lists it as a reserved sub-delimiter. Reserved means “may carry a meaning inside a specific component” — not “means space”. In a path segment, no scheme assigns it one, so it decodes to itself.
Form encoding predates that and does assign it. The application/x-www-form-urlencoded serializer in the WHATWG URL spec emits + for U+0020, and the parser reverses it. HTML forms have shipped that since the early 90s, so every query-string parser inherited it.
URLSearchParams is a form-urlencoded parser, not a URI parser. That is the whole story — it is not being clever, it is following a different spec than URL.pathname does.
The confusion is worse because JavaScript’s standalone helpers land on both sides of the line:
decodeURIComponent('a+b'); // 'a+b' — URI rules, plus survives
encodeURIComponent('a b'); // 'a%20b' — never emits +
encodeURIComponent('a+b'); // 'a%2Bb' — escapes a literal plus
encodeURI('a+b'); // 'a+b' — leaves reserved chars alone
new URLSearchParams({ q: 'a b' }).toString(); // 'q=a+b'
So encodeURIComponent and URLSearchParams produce different encodings of the same string, and both are valid. %20 is also accepted as a space by form parsers, which is why encodeURIComponent is safe in a query and encodeURI is not.
Server-side runtimes split the same way, along the same line — form decoder versus URI decoder:
| Language | Turns + into space | Leaves + alone |
|---|---|---|
| Python | unquote_plus, parse_qs | unquote |
| Go | url.QueryUnescape, url.ParseQuery | url.PathUnescape |
| PHP | urldecode, $_GET | rawurldecode |
| Java | URLDecoder.decode | — |
Verified in Python 3:
from urllib.parse import unquote, unquote_plus, parse_qs
unquote('a+b') # 'a+b'
unquote_plus('a+b') # 'a b'
parse_qs('q=a+b') # {'q': ['a b']}
Pick the wrong one of a pair and you get exactly the bug above, in either direction.
What to do
Never build a query string by concatenation. Let the encoder handle it:
// wrong — token goes in raw
const bad = `https://api.x.test/v/?t=${token}`;
// right
const url = new URL('https://api.x.test/v/');
url.searchParams.set('t', token);
url.toString(); // ?t=%2Bvv8%2Ff7%2F
searchParams.set escapes the + to %2B, and any conforming parser on the other end gives you the original bytes back.
For binary in URLs, use base64url. RFC 4648 §5 swaps + and / for - and _, which are unreserved, so the value survives a URL untouched:
const b64url = Buffer.from(token, 'base64').toString('base64url');
b64url; // '-vv8_f7_'
new URLSearchParams('t=' + b64url).get('t'); // '-vv8_f7_' ← intact
This is why JWTs use base64url. If you are minting tokens, do the same and the problem cannot occur.
When you must decode by hand, match the decoder to the component. unquote for a path, unquote_plus for a query. In Go, PathUnescape and QueryUnescape exist as separate functions for precisely this reason.
Percent-encode + in signed values before signing, not after. If the signature covers the encoded form and a proxy re-encodes it, verification breaks for reasons that have nothing to do with the key.
Caveats
- This is not a JavaScript quirk.
URLSearchParamsis behaving exactly as specified; every mainstream language has the same two-decoder split. %20works in both components. If you only ever emit%20, this class of bug disappears — but you still have to decode correctly, because someone else’s client will send+.- Some frameworks decode the query twice (once at the router, once in a middleware). Double-decoding turns
%2Binto+into a space, restoring the bug after you fixed it. If a correct encoding still arrives wrong, count the decodes. - The path is not entirely safe either —
+is literal there, but;and=carry meaning in some path-parameter conventions, and servers differ on%2Fin a path segment. - Fragments never reach the server, so a
+in a fragment is only your client’s problem.
References
- RFC 3986 §2.2 — Reserved Characters
- WHATWG URL — application/x-www-form-urlencoded serializing
- WHATWG URL — application/x-www-form-urlencoded parsing
- RFC 4648 §5 — Base 64 Encoding with URL and Filename Safe Alphabet
- MDN — URLSearchParams
- MDN — encodeURIComponent
- Python — urllib.parse.unquote and unquote_plus
- Go — net/url QueryUnescape and PathUnescape