Desk Clarity

Everyday document & browser help

URLSearchParams Changes a Base64 Plus to a Space: Serialize the Raw Value Once

By ON Lab · Sources checked November 8, 2026

A plus sign in a string passed to URLSearchParams is parsed as a space under form-query rules. If a Base64 value must retain a literal +, pass the unencoded value through append or set rather than interpolating it into a query string. Compare the decoded value and its bytes after the round trip.

Use a value whose corruption is visible

A historical MDN report described a Base64 query value changing when read through get. We used a different two-byte fixture: bytes fb ff encode as +/8=. The following runs locally in Node without requesting any URL:

const value = Buffer.from([251, 255]).toString('base64');
const bad = new URLSearchParams('bin=' + value);
console.log(value);
console.log(JSON.stringify(bad.get('bin')));

Node 24.22.2 printed +/8= and then " /8=". The leading plus had already become a space before Base64 decoding. The query parser did not know that this field represented binary data.

Give the serializer an unencoded value

const value = Buffer.from([251, 255]).toString('base64');
const params = new URLSearchParams();
params.append('bin', value);
const query = params.toString();
const restored = new URLSearchParams(query).get('bin');
console.log(query);
console.log(restored);
console.log(Buffer.from(restored, 'base64').toString('hex'));

Our result was bin=%2B%2F8%3D, then the original +/8=, then fbff. The serializer escaped the literal plus, slash and equals sign; the later parser restored the original value. Escaped wire spelling and decoded data are different representations of the same intended content.

Do not encode the same value twice

const params = new URLSearchParams();
params.append('bin', encodeURIComponent('+/8='));
console.log(params.toString());
console.log(params.get('bin'));

The value read back in our fixture was %2B%2F8%3D, not +/8=. The existing percent signs were themselves serialized. Choose one layer to own the encoding. Passing pre-encoded text to an API expecting a raw value creates a different contract; adding another decode call later can hide that mismatch rather than correct the producer.

A decoder’s success is not proof of preservation

We compared the corrupted value in two actual environments. Node’s Buffer decoder accepted it and returned hex ff, losing the first original byte. The guest browser’s atob rejected it with InvalidCharacterError. Both environments’ URLSearchParams had already returned the same space-containing value.

That difference is why the acceptance check should compare the original value and decoded bytes, not only whether decoding raised an exception. Our browser test used the guest Chromium-based runtime; it did not change browser settings or contact an API.

Keep query rules separate from a URL’s path

const address = new URL('https://example.invalid/a+b?q=a+b');
console.log(address.pathname);
console.log(address.searchParams.get('q'));

Our Node fixture retained /a+b in the path and read a b for q. This does not mean every plus anywhere in a URL is a space. Also, a complete URL should be parsed with new URL and then its searchParams; URLSearchParams by itself processes query syntax rather than identifying a host and path.

Verify the actual boundary

  1. Identify whether the input is raw field data, an encoded query string, or a complete URL.
  2. Preserve a synthetic value containing a plus, slash and padding character.
  3. Serialize the raw value once, parse it once and compare the complete restored string.
  4. If binary decoding follows, compare the resulting bytes.
  5. Only then investigate a real client/server boundary through its documented contract.

Do not alter real tokens to test this behavior or log them publicly. A change to a URL-safe Base64 alphabet is a separate protocol decision, not a unilateral client repair. The standard API and original public documentation are free alternatives; no paid encoder is needed.

Method: current MDN and Node 24 documentation, a historical MDN user report, and original Node 24.22.2 plus guest-browser fixtures. No HTTP request, credential, live token, server framework or consumer Chrome settings were tested.

Sources