DEV Community

Cover image for Nested query strings in depth — a complete technical guide
Ali nazari
Ali nazari

Posted on

Nested query strings in depth — a complete technical guide

This guide explains everything you need to know about nested query strings (e.g. ?filter[where][name]=John&filter[where][age][lt]=30), the relevant standards and RFCs, how browsers/servers treat them, how to handle them safely and efficiently in Node.js/Express, and practical tips, traps and code you can drop into a real app.

TL;DR (if you want the short map)

  • a[b][c]=x is conventionally parsed into { a: { b: { c: "x" } } } by parsers that support bracket notation (e.g. qs).

  • The canonical spec for URIs is RFC 3986; form encoding and application/x-www-form-urlencoded behavior is defined by the WHATWG URL standard / HTML form specs (and legacy RFCs). Percent-encoding rules are different in these specs — encode reserved characters when in doubt.

  • In Node/Express you can parse nested queries with qs (Express "extended" parser uses qs) or use native URLSearchParams / querystring for simpler cases. Know qs options (depth, parameterLimit, plainObjects, allowPrototypes, arrayLimit) and harden them.

  • Security: prototype pollution (historical qs advisories), NoSQL injection, parameter-explosion DoS. Validate, whitelist, and sanitize before using parsed objects in DB queries.


What does ?filter[where][name]=John&filter[where][age][lt]=30 mean?

That query string is using bracket notation to express a nested object. A parser that understands bracket syntax (like qs) will produce:

{
  filter: {
    where: {
      name: "John",
      age: { lt: "30" } // note: values are strings by default
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Key implications:

  • Bracket syntax is not special to HTTP itself — it's a convention used by many web frameworks and client libraries. The parsing behavior depends on the parser used by the server or client.

  • Values are strings unless you coerce them (e.g.Number(), schema validators).

  • Equivalent bracket forms can represent arrays (e.g. a[]=x&a[]=ya: ["x","y"]) or indexed arrays (a[0]=x&a[1]=y). Use parser options to control these behaviors.


Standards and specs you should know

RFC 3986 — URI generic syntax

RFC 3986 is the canonical definition of URIs (scheme, authority, path, query, fragment) and how characters are percent-encoded in URIs.

The query component is defined but many practical details (like HTML form encoding) are specified elsewhere. If you care about what characters must or may be percent-encoded in a URI, read RFC 3986.

WHATWG URL Standard / HTML form encoding

The WHATWG URL Standard (and HTML form submission rules) describe application/x-www-form-urlencoded semantics, including space encoding as + vs %20, how form submits are encoded and how browsers typically encode form/query values.

For practical interoperability and building URLs programmatically, follow WHATWG's rules

Practical rule: percent-encode reserved characters (including [ and ]) when constructing URLs programmatically unless you know all clients tolerate raw brackets.

Many libraries (qs.stringify()) will encode correctly for you.


Parsers available in Node / Browser: behavior and differences

Native browser URLSearchParams

  • Modern and standardized, available in browsers and Node (globalThis / url.URLSearchParams).

  • Good for simple key=value pairs and handling repeated keys. It does not produce nested objects from bracket notation — keys are kept as strings ("filter[where][name]") unless you post-process them.

  • Use when query structure is flat or you control client and server and prefer explicit parsing. (Works for most single-value needs.)

Node querystring (legacy)

Node historically offered querystring.parse which treats keys literally (no nested object parsing from brackets).

Node marks querystring as legacy in favor of URLSearchParams.

Use only if you need its exact behavior or for legacy code.

qs (de facto standard for nested parsing)

qs supports bracket notation, creating nested objects and arrays, configurable depth, arrayLimit, parameterLimit, and more.

Widely used in Express (via "extended" parser) and many frameworks.
npm

qs.stringify() will properly encode keys (percent-encoding) and generate query strings that qs.parse() will invert, which makes client/server symbiosis easy.


Express and app.set('query parser', ...)

app.set('query parser', 'simple') — uses Node's simple parser (literal keys, no nesting).

app.set('query parser', 'extended') — uses qs style parsing (nested objects). This is the option you want if you expect a[b][c] semantics.

You can also pass a custom function to that setting to call qs.parse() with custom options or implement your own parser.

Note: set this early in your app initialization (before other middleware) — changing it later can be ineffective.

const qs = require('qs');
app.set('query parser', (str) => qs.parse(str, { ignoreQueryPrefix: true }));
Enter fullscreen mode Exit fullscreen mode

qs—important options (and safe defaults)

qs.parse(str, opts) and qs.stringify(obj, opts) have knobs you must know about.

depth — maximum nesting depth parsed into objects. Prevents extremely deep payloads that CPU-bind the parser. (Set to a small reasonable value like 5–10 for public endpoints.)
npmdoc.github.io

parameterLimit — maximum number of parameters to parse. Prevents parameter explosion DoS.
Vulnerability Finder

arrayLimit — max number of array items parsed from numeric indices.

plainObjects — when true, create result objects with Object.create(null) (no prototype) — this reduces prototype-pollution surface.

allowPrototypes — if true parsed keys can populate prototype properties (dangerous). Keep allowPrototypes: false.

ignoreQueryPrefix — ignore leading ? when parsing raw query strings.

Example safe parser for Express:

const qs = require('qs');

app.set('query parser', (str) =>
  qs.parse(str, {
    ignoreQueryPrefix: true,
    depth: 6,
    parameterLimit: 2000,
    arrayLimit: 200,
    plainObjects: true,
    allowPrototypes: false
  })
);
Enter fullscreen mode Exit fullscreen mode

Security threats & mitigations

Prototype pollution (real-world qs advisories)

qs has had prototype-pollution advisories (GHSA/CVE entries). Prototype pollution can let an attacker set proto properties via crafted query strings, which can change program behavior or escalate privilege. Mitigations:

  • Keep qs up to date.

  • Use plainObjects: true and allowPrototypes: false as parser options to avoid populating prototypes.

  • Never merge untrusted parsed objects directly into application-level prototypes or trusted config objects.

NoSQL / Database injection

If you accept arbitrary nested objects and pass them to User.find(req.query.filter) or similar, an attacker might submit Mongo operators or crafted objects to change queries (e.g. $ne, $where, $gt, or dot operators). Defenses:

  • Use sanitizers (e.g. express-mongo-sanitize) to remove leading $ keys and dotted keys.

  • Validate shapes with JOI/Zod/TypeBox and coerce types (numbers, booleans).

  • Whitelist allowed fields and operators; map client operators to DB operators explicitly (do not pass user keys straight through).

Parameter explosion / DoS

Very deep or very large query payloads can block the event loop or consume memory. Mitigations:

  • depth, parameterLimit, arrayLimit in qs.
  • Rate-limiting and size-checking at upstream layers.

Logging / exposure

Don't log entire req.query for every request in production (attacker could send huge or malicious content). Truncate logs or apply sampling.


Performance considerations

qs parsing is reasonably fast for typical queries, but extremely deep or huge queries can block the event loop — hence depth/parameter limits and rate limiting are important.

If you only need flat queries, prefer URLSearchParams (native) for a simpler, faster path.

Avoid synchronous JSON parsing of very large query values in hot paths.

Bracket-style nested query strings are a convenient and compact way to express structured filters in GET requests, but they also bring complexity and attack surface.

Treat query parsing as an important part of your input validation layer

💡 Have questions? Drop them in the comments!

Top comments (0)