Skip to main content

URLSearchParams: Stop Encoding Your Query Values Twice

Updated 5 min readMarkdown
URLSearchParams: Stop Encoding Your Query Values Twice

A search for C++ & café arrives at your API as C followed by spaces, or as a string full of %25. The request is syntactically valid. Somewhere between the input and the handler, the application confused a value with the query syntax carrying it.

The dependable rule for ordinary query parameters is to pass raw strings to URLSearchParams and let it serialise them once. Do not call encodeURIComponent() before .set(). When reading parameters, use the decoded value returned by .get(); do not decode it again.

That rule fixes a large class of bugs, but it needs one boundary: query encoding is different from path-segment encoding, and neither makes an untrusted URL destination safe.

Start with a round trip, not a pretty URL

Consider an illustrative filter value containing spaces, a literal plus, an ampersand, a slash and a non-ASCII character:

const original = 'C++ & café/notes';
const url = new URL('https://example.com/search');
url.searchParams.set('q', original);

console.log(url.href);
console.log(new URL(url.href).searchParams.get('q'));

The serialised URL is:

https://example.com/search?q=C%2B%2B+%26+caf%C3%A9%2Fnotes

The second output is the original string. The success criterion is that round trip, rather than whether the encoded URL looks familiar.

URLSearchParams uses form-style query encoding: a space serialises as +, while a literal plus becomes %2B. The WHATWG URL Standard defines parsing and serialisation. A different valid URL producer might express spaces as %20; textual differences alone do not prove that values differ.

This matters for debugging. Compare the value received by the application with the original input before deciding that the presence of + in the address is an error.

Double encoding preserves the wrong representation

This looks cautious and is wrong for this API:

const params = new URLSearchParams();
params.set('q', encodeURIComponent('tea & cake'));
console.log(params.toString());
// q=tea%2520%2526%2520cake
console.log(params.get('q'));
// tea%20%26%20cake

The percent sign in the pre-encoded string is itself data to URLSearchParams. It becomes %25 during serialisation. One parse then returns the intermediate encoded representation instead of tea & cake.

Removing every %25 from a URL is not a repair. A user may genuinely search for the characters %20, and a blanket replacement changes their query. Fix the boundary that encoded a component twice.

A similar mistake happens on reading:

const params = new URLSearchParams('q=100%25');
const value = params.get('q'); // '100%'
// decodeURIComponent(value) would throw: '%' is incomplete encoding.

The parser has already decoded the transport representation. Application values are allowed to contain percent signs. Decoding again treats those values as another layer of syntax that the protocol did not specify.

Interpolation gives special characters structural meaning

Compare these constructions:

const query = 'red & blue';
const broken = new URLSearchParams(`q=${query}`);
const correct = new URLSearchParams({ q: query });

console.log([...broken]);
// [ ['q', 'red '], [' blue', ''] ]
console.log([...correct]);
// [ ['q', 'red & blue'] ]

The string constructor parses existing query syntax. The object constructor accepts keys and values. Using one where you meant the other lets an ampersand start a second parameter.

Literal plus signs have the same boundary problem. Parsing token=a+b gives a value containing a space. If your raw token is a+b, set that raw value with .set() or .append() so serialisation produces a%2Bb.

Do not solve this by replacing every plus sign in all URLs. Decide whether you have a raw value, an encoded component or an entire URL, and use the corresponding operation.

Repeated parameters need a contract

For a multi-select filter, repeated keys can be intentional:

const url = new URL('https://example.com/articles');
url.searchParams.append('tag', 'javascript');
url.searchParams.append('tag', 'security');

console.log(url.searchParams.getAll('tag'));
// [ 'javascript', 'security' ]

.get() returns one value, while .getAll() returns all matching values. .set() replaces the existing values for a name. A backend that accepts only one occurrence should reject or consistently handle duplicates; otherwise different layers can interpret the same request differently.

Write down whether a field uses repeated keys, a single JSON value or a comma-separated grammar. The last option needs rules for values that themselves contain commas. Encoding cannot invent a missing API contract.

For numeric-looking identifiers, keep the raw string through this boundary. Converting to Number before adding the parameter can lose digits before encoding begins. Jitsumi's large JSON ID walkthrough follows that separate precision failure through an API response.

A runnable boundary check

Save this as query-roundtrip.mjs and run it with Node.js. It uses only built-in modules:

import assert from 'node:assert/strict';

for (const raw of ['C++', 'red & blue', '100%', 'café/notes', '%20']) {
  const url = new URL('https://example.com/search');
  url.searchParams.set('q', raw);
  assert.equal(new URL(url.href).searchParams.get('q'), raw);
}

const bad = new URLSearchParams({ q: encodeURIComponent('a b') });
assert.equal(bad.get('q'), 'a%20b');
assert.notEqual(bad.get('q'), 'a b');
console.log('Five raw values round-trip; double encoding does not.');

This checks query-value round trips. It does not establish how a particular proxy, signature scheme or backend framework normalises a complete request target. Add an integration check at the receiving handler when that infrastructure matters.

Keep paths and destinations separate

Use query parameters for filters that belong in a query. If an API puts a value in a path segment, follow its route contract and encode the segment appropriately. An encoded slash can still interact with server routing and intermediary normalisation, so do not assume arbitrary filenames make good path identifiers.

Likewise, safely serialising redirect=https://... does not approve that destination. A redirect handler still needs its own allowed-destination policy. Encoding preserves text; it grants no authority.

When repairing a broken query, inspect three values: the raw input, the serialised URL and the parsed value at the handler. The first point where those meanings diverge is the boundary to fix.

References

About the author

  • We are a small team of developers with one goal in mind. Pushing low spec PCs to the limit.

The "inspect three values: raw input, serialised URL, parsed value at the handler" step is a clear way to find where meaning diverges. One related trap we would add: form submissions and URLSearchParams agree on + for spaces, but some server frameworks decode the query with plain percent-decoding and leave + as a literal plus. Your round-trip test passes in Node, yet the handler can still see "tea+cake". Adding one integration check with a value containing a space, as you suggest at the end, is the cheapest way to catch that.