Ch. 6 · Node.js

Node.js URL and URLSearchParams

Build query strings with URLSearchParams, resolve relative URLs against a base, and check protocol and host before your server fetches.

~7 min readintermediateupdated Oct 19, 2026

URLs look like strings, so people build them with string concatenation. That works until a value contains a character that means something in a URL, and then the request goes somewhere you didn’t intend, sometimes harmlessly (a broken search) and sometimes not (a server-side request forgery bug). Node has shipped the WHATWG URL and URLSearchParams classes as globals since Node 10, the same API browsers use. This guide shows what they get right that concatenation doesn’t, the two parsing rules that surprise people, and how to check a URL before your server fetches it. All output below came from Node 22.23.

Building query strings without breaking them

A search box sends a user’s query to an internal API. The quick version:

const q = 'fish & chips=yes';
const naive = `https://shop.example.com/search?q=${q}&page=2`;
console.log([...new URL(naive).searchParams]);
JavaScript
[ [ 'q', 'fish ' ], [ ' chips', 'yes' ], [ 'page', '2' ] ]
Text

The & in the user’s text ended the q parameter, and the = created a new parameter called ' chips'. The API searched for fish and received a parameter it never asked for. With hostile input, that’s parameter injection: a user who types x&admin=true gets to add admin=true to your server-to-server call.

Let URLSearchParams do the encoding instead:

const url = new URL('https://shop.example.com/search');
url.searchParams.set('q', q);
url.searchParams.set('page', '2');
console.log(url.href);
console.log([...url.searchParams]);
JavaScript
https://shop.example.com/search?q=fish+%26+chips%3Dyes&page=2
[ [ 'q', 'fish & chips=yes' ], [ 'page', '2' ] ]
Text

The value round-trips exactly. url.searchParams is live, too: setting a parameter updates url.href and url.search immediately, so there’s no separate step to rebuild the string.

Notice the spaces became +. URLSearchParams follows the application/x-www-form-urlencoded format that HTML forms use, where a space is +. encodeURIComponent('fish & chips') gives fish%20%26%20chips instead. Both decode correctly on any server that parses query strings properly, but if you’re computing a signature over the URL (AWS Signature V4, an HMAC on webhook URLs), the exact bytes matter, and you need to use whichever encoding the signing scheme specifies.

A few behaviours of URLSearchParams that come up when reading parameters:

const p = new URLSearchParams('tag=node&tag=sql&sort=');
p.get('tag');      // 'node'  (first value only)
p.getAll('tag');   // [ 'node', 'sql' ]
p.get('sort');     // ''      (present but empty)
p.get('limit');    // null    (absent)
JavaScript

get returning null for a missing key and '' for an empty one is a distinction worth keeping: if (!p.get('sort')) treats both the same, which may or may not be what you mean. Repeated keys are common in filters (?tag=node&tag=sql), and code that calls get silently drops all but the first.

Constructing from an object converts every value to a string, which catches arrays out:

from object: ids=1%2C2%2C3&on=true
Text

{ ids: [1, 2, 3] } became ids=1,2,3 (encoded), not three ids parameters. If the API expects repeated keys, call append('ids', id) in a loop.

Resolving URLs against a base

new URL(input, base) resolves a relative reference the way a browser resolves a link on a page. That’s a feature, but it follows browser rules, and the trailing slash on the base changes the result:

base with slash:    https://api.example.com/v1/orders?id=7
base without slash: https://api.example.com/orders?id=7
leading slash:      https://api.example.com/orders
absolute input:     https://evil.test/x
Text

Without the trailing slash, v1 is treated as a “file” in the base and replaced, so the request silently loses its API version. A relative input that starts with / replaces the whole path. And an absolute input ignores the base entirely. That last line is the dangerous one: if you build new URL(userInput, 'https://api.example.com/') thinking the base keeps requests on your API, any user who supplies a full URL sends your server wherever they like. Base resolution is for joining trusted paths, not for constraining untrusted ones.

Parsing errors are exceptions. new URL('api.example.com/orders') (no scheme, no base) throws a TypeError with code ERR_INVALID_URL and message Invalid URL. For user input, URL.canParse(input) (Node 19.9 and later) returns a boolean, and URL.parse(input) (Node 22.1 and later) returns null instead of throwing, which reads more cleanly than a try/catch around a constructor.

The older url.parse() from node:url is deprecated. It doesn’t follow the WHATWG rules, it has parsed some hostnames differently from browsers, and that kind of disagreement between two parsers is exactly what URL-validation bypasses exploit. Use new URL everywhere.

The parser normalises hosts, so check the parsed result

Here’s what the parser does to some inputs an attacker might send:

http://example.com@evil.test/              -> host "evil.test" href http://example.com@evil.test/
http://127.1:8080/                         -> host "127.0.0.1" href http://127.0.0.1:8080/
http://2130706433/                         -> host "127.0.0.1" href http://127.0.0.1/
http://[::1]/                              -> host "[::1]" href http://[::1]/
HTTP://API.Example.COM./a/../b             -> host "api.example.com." href http://api.example.com./b
http://169.254.169.254/latest/meta-data/   -> host "169.254.169.254" href http://169.254.169.254/latest/meta-data/
Text

Each line defeats a string-based check. example.com@evil.test starts with the trusted name, but everything before @ is a username, and the host is evil.test. 127.1 and the decimal 2130706433 are both loopback once parsed, so a blocklist that looks for the text 127.0.0.1 misses them. Hosts are lowercased and may keep a trailing dot, which is still the same DNS name. The last one is the cloud metadata endpoint on AWS and several other providers, the classic target of server-side request forgery (SSRF), since it can hand out credentials to whoever can make the server request it.

The lesson: parse first, then make every decision on url.protocol, url.hostname and url.port. Never on the input string, and never with startsWith or includes.

Validating a URL before your server fetches it

Say users can set an avatar by URL, and your server downloads the image. If you know where avatars legitimately come from, an allowlist is the strongest check:

const ALLOWED_HOSTS = new Set(['images.example-cdn.com', 'avatars.example.com']);

function parseAllowedUrl(input) {
  const url = URL.parse(input);
  if (!url) throw new Error('not a valid absolute URL');
  if (url.protocol !== 'https:') throw new Error(`protocol ${url.protocol} not allowed`);
  if (url.username || url.password) throw new Error('credentials in URL not allowed');
  if (url.port && url.port !== '443') throw new Error(`port ${url.port} not allowed`);
  const host = url.hostname.replace(/\.$/, '');
  if (!ALLOWED_HOSTS.has(host)) throw new Error(`host ${host} not allowed`);
  return url;
}
JavaScript
ok   https://images.example-cdn.com/u/42.png
ok   https://images.example-cdn.com./u/42.png
deny http://images.example-cdn.com/u/42.png -> protocol http: not allowed
deny https://images.example-cdn.com@169.254.169.254/latest/meta-data/ -> credentials in URL not allowed
deny https://images.example-cdn.com.evil.test/u/42.png -> host images.example-cdn.com.evil.test not allowed
deny https://images.example-cdn.com:8443/u/42.png -> port 8443 not allowed
deny file:///etc/passwd -> protocol file: not allowed
deny //images.example-cdn.com/u/42.png -> not a valid absolute URL
Text

The uppercase, trailing-dot variant is accepted because the parser lowercased it and the code strips the dot; it really is the same host. The suffix trick (.evil.test) fails because the comparison is exact set membership, not endsWith. If you need to allow subdomains, check host === domain || host.endsWith('.' + domain), with the leading dot.

Two more steps finish the job. fetch follows redirects by default, so an allowed host that redirects to 169.254.169.254 gets you right back to the attack. Pass redirect: 'manual' and validate each Location yourself, or turn redirects off. And when you can’t use an allowlist (webhooks, where customers choose the destination), checking the hostname isn’t enough, because a public name can resolve to a private address, and can change its answer between your check and your request (DNS rebinding). Resolve the name with dns.lookup, reject loopback, private, link-local and metadata ranges, then connect to the IP you checked, or route outbound calls through an egress proxy that enforces the same rules.

Practice question: the image proxy

“Our Node service has GET /proxy?src=... that fetches src and returns it, so the front end can display third-party images through our domain. Security found they could read http://169.254.169.254/latest/meta-data/ through it. The developer’s fix was if (!src.startsWith('https://images.partner.com')) return 403. Is that enough? What would you change?”

Answer

No. startsWith checks the text, not the destination. https://images.partner.com@169.254.169.254/latest/meta-data/ starts with the allowed prefix, but the parser treats images.partner.com as a username and the host as the metadata IP. Ignoring the protocol, https://images.partner.com.attacker.test/ passes too, and the attacker controls that domain’s DNS. Even a correct-looking URL can redirect to an internal address, because fetch follows redirects.

I’d parse src with URL.parse, reject anything that isn’t https:, reject URLs with a username or password, and compare url.hostname (lowercased by the parser, trailing dot stripped) against an exact allowlist of partner image hosts. I’d fetch with redirect: 'manual' and either refuse redirects or run each Location through the same check, with a small hop limit. I’d also limit what the proxy returns: only image/* content types, a size cap, and a short timeout, so it can’t be used to pull other kinds of data or tie up the server. On the infrastructure side, requiring IMDSv2 on AWS (which needs a PUT with a token header first) makes the metadata endpoint much harder to reach through an SSRF that can only send GET requests, but it’s a second layer, not a substitute for validating the URL.

Further reading

More in Node.js

read ✓Node.js · hard

Node.js Password Hashing with scrypt

Hash passwords in Node.js with crypto.scrypt, a random salt and stored cost parameters, then verify logins with timingSafeEqual.

~7 min readread →
read ✓Node.js · mid

Node.js HTTP Security Headers

Add CSP, nosniff, Referrer-Policy and HSTS to every Node.js response from one function, error pages included, and roll out CSP safely.

~7 min readread →
read ✓Node.js · mid

Node.js Request Body Validation and Limits

Read a JSON request body in Node.js with a byte limit that also catches chunked uploads, then validate its shape and return 413, 415, 400 or 422.

~6 min readread →
esc