Query Parameters
The least standardised part of an HTTP request, parsed differently by every stack, and the usual entry point for unbounded work.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
The query string has no schema and no agreed parsing rules. How do you accept one safely?
GET /orders should let a client filter by status, sort by a field, and page through results without downloading everything.
Read req.query.status, req.query.sort and req.query.limit, pass them to the query builder, return the rows. The framework parsed the query string already.
?limit=1000000 returns a million rows: the database does the work, your process serialises it, and one request occupies a connection and a large slab of memory for a long time (Connection Pools, What Serialization Costs).
?limit=1000000returns a million rows: the database does the work, your process serialises it, and one request occupies a connection and a large slab of memory for a long time (Connection Pools, What Serialization Costs).?sort=name;DROPor?sort=(select ...)reaches a query builder by string concatenation, because sort columns cannot be bound as parameters (SQL Injection).?status=open&status=closedmakesreq.query.statusan array in Express and a string in a stack that keeps the first value — sostatus.trim()throwsTypeErrorin production and never in a test.?filter[price][gte]=0builds a nested object in Express's defaultqsparser, and that object goes straight into an ORMwhereclause, letting the caller write arbitrary predicates (Mass Assignment and Over-Posting).- Deep paging:
?offset=500000makes the database count and discard half a million rows for every page near the end (Offset Pagination: Simple, Jumpable, and Lying Under Writes).
What is actually happening
- There is no standard for query-string structure. The URL spec defines a query as opaque characters after
?. Everything else —key=value,&separators,+for space, repeated keys, bracket syntax for nesting — is convention inherited from HTML form encoding and implemented differently everywhere. - Because there is no standard, the type of a query value depends on the input. In Express (via
qs) a repeated key becomes an array and bracket syntax becomes an object; in Go,r.URL.Query()always returns[]stringand.Get()takes the first; in the Java Servlet APIgetParameterreturns the first andgetParameterValuesreturns all; PHP's$_GETand Rails'paramsboth build nested structures from brackets. The same URL yields different shapes in different services. - The consequence is that a query parameter is not one value with an unknown content — it is an unknown shape with unknown content. Code that assumes "string" is making a claim about the parser, not about the request.
- Duplicated keys across hops are a security-relevant ambiguity: if a WAF or gateway reads the first occurrence and the application reads the last, a rule can be evaluated on a different value than the one that takes effect. This is HTTP parameter pollution.
- Query values do not participate in routing (How a Route Becomes a Function Call) but they do participate in caching: a cache key that includes parameter order or unknown tracking parameters fragments the cache for no benefit.
The same URL, four different objects
Before deciding how to validate a query, it is worth seeing how little agreement there is about what a query even is. The row that matters most is the second one: a caller can change the type of a value by repeating a key, and code that then calls a string method on it throws.
This is why the recommendation is a schema over the whole query object rather than defensive checks at each use. A schema converts an unknown shape into a known one exactly once, and everything downstream can be written without hedging.
| Input | Express (`qs` default) | Go `r.URL.Query()` | Java Servlet | Rails / PHP |
|---|---|---|---|---|
?tag=a | "a" | ["a"], .Get() → "a" | getParameter → "a" | "a" |
?tag=a&tag=b | ["a","b"] | ["a","b"] | getParameter → "a"; getParameterValues → both | "b" (last wins) |
?tag[]=a&tag[]=b | ["a","b"] | ["a"],["b"] under the literal key tag[] | key is literally tag[] | ["a","b"] |
?f[min]=1&f[max]=9 | { min: "1", max: "9" } | literal keys f[min], f[max] | literal keys | { "min" => "1", "max" => "9" } |
Parse the query once, into a type
The shape of the fix is the same in every language: one schema per endpoint, applied before the handler, producing a value whose type carries the caps. The handler then has no decisions left to make about the query, and the tests for "what happens with limit=0" live next to the schema instead of scattered through the handler.
Note what the schema does beyond validation: it sets defaults, it enforces a maximum the caller cannot exceed, it converts strings to the types the domain uses, and it rejects unknown keys. That last one is the difference between a client typo returning an error and a client typo returning unfiltered data.
1const ListOrders = z.object({2 status: z.enum(['open', 'paid', 'cancelled']).optional(),3 sort: z.enum(['created_at', 'total']).default('created_at'),4 dir: z.enum(['asc', 'desc']).default('desc'),5 limit: z.coerce.number().int().min(1).max(100).default(20),6 cursor: z.string().max(256).optional(),7}).strict() // unknown keys are an error, not a shrug8 9const SORTABLE = { // allowlist: token -> expression10 created_at: 'o.created_at',11 total: 'o.total_cents',12} as const13 14app.get('/orders', async (req, res) => {15 const q = ListOrders.safeParse(req.query)16 if (!q.success) return res.status(400).json(toFieldErrors(q.error))17 18 const rows = await repo.listOrders({19 tenantId: req.ctx.principal.tenantId, // scope from identity, never from the query20 status: q.data.status,21 orderBy: SORTABLE[q.data.sort], // an expression we wrote, not a string we received22 dir: q.data.dir,23 limit: q.data.limit,24 cursor: q.data.cursor,25 })26 res.json(toPage(rows, q.data.limit))27})Two lines carry most of the safety: .max(100) makes the cap non-negotiable, and SORTABLE[...] means the caller chooses from a set you wrote rather than supplying SQL fragments. .strict() is the third — it turns typos into 400s.
Every list endpoint is a pagination decision
A query parameter is only dangerous because of the work it authorises. That makes the pagination strategy the real subject: it decides the worst case a single request can cost you, and it decides what happens when the underlying data changes between pages.
There is no default winner here. Offset pagination is trivially implementable and gives clients page numbers; cursor pagination is stable under concurrent writes and cheap at any depth but cannot jump. Choosing needs the access pattern, not a preference (Pagination That Survives a Large Table).
What will clients actually do with this list, and how large can it get?
when The collection is bounded by construction — a user's payment methods, a tenant's API keys. Cap it anyway and return an error above the cap.
cost The cap is a latent bug the day a tenant exceeds it; you need an alert on approach, not just an error at the edge.
when Clients need page numbers or random access, the collection is small, and writes are rare relative to reads.
cost Cost grows with depth, and concurrent inserts skip or duplicate rows across pages with no error (Offset Pagination: Simple, Jumpable, and Lying Under Writes).
when Deep paging, infinite scroll, or any collection under active write load.
cost No jumping to page N; the sort key must be unique and stable; the cursor is opaque and needs versioning if its encoding changes (Cursor Pagination: An Opaque Bookmark, Not a Position).
when The client genuinely wants everything — reporting, migration, analytics.
cost A whole asynchronous surface: job status, result storage, expiry, authorisation on the artefact (Background Jobs, The Async Job Pattern).
when A large result must arrive as it is produced and the client can consume incrementally.
cost Errors after the first byte cannot change the status code; back-pressure and slow clients become your problem (Slow Clients and Backpressure).
How to build it
Most important first.
- Parse the whole query object once, at the boundary, into a typed struct with defaults and hard caps. One schema per endpoint, applied before any handler logic (Transport Validation).
- Cap every unbounded number.
limitgets a maximum, not just a default; page sizes, date ranges and expansion depths all get ceilings the client cannot raise. - Allowlist anything that names a column or a relation.
sort,fields,include,expandandordermap from a fixed set of accepted tokens to a fixed set of expressions. Never interpolate the token. - Decide array semantics explicitly and document them: repeated key, comma-separated, or single value only. Then enforce it — reject the forms you did not choose rather than silently accepting three.
- Prefer cursor pagination for anything a client will page deeply, and be explicit that the cursor is opaque and server-signed if it encodes anything but a key (Cursor Pagination: An Opaque Bookmark, Not a Position).
- Return a structured 400 naming the offending parameter. Query parameters are the part of the API developers get wrong most often, and a good error message is a support-cost decision (Reporting Validation Failures).
What can go wrong
- A default
limitwith no maximum — the most common form of "one client can degrade the service for everyone". - A parameter that becomes an ORM predicate object, giving the caller the expressive power of your query language.
- Boolean parsing by truthiness:
?active=falseis the non-empty string"false", which is truthy in several languages, so the filter inverts. - A parameter that carries a URL for the server to fetch, which is SSRF with extra steps (SSRF — When the Backend Fetches a URL).
- Cache fragmentation from parameter order or from marketing parameters appended by clients; two identical requests, two cache entries (Caching as a Contract Clause).
- Silently ignoring unknown parameters, so a client typo (
?statuss=open) returns unfiltered data with a 200 — arguably the worst possible response.
- Offset pagination over a table receiving writes shifts rows between requests: a row inserted before the current offset pushes another row onto a page the client already fetched, so items are skipped or duplicated with no error anywhere (Cursor Pagination: An Opaque Bookmark, Not a Position over a stable key avoids this).
- A filtered count and the page of rows fetched as two statements can disagree, so a client sees "12 results" and eleven rows.
- Injection wherever a parameter names something rather than being compared to something: sort columns, table hints, field selections, file paths, outbound URLs.
- Parameter pollution as a filter bypass when two hops disagree about which duplicate wins. Normalise at the edge, or reject duplicates for parameters that matter.
- Resource exhaustion as a denial-of-service vector: a large
limit, a wide date range, a deepincludegraph, or an expensivesearchterm are all "one request, unbounded work" (Resource Limits). - Scope escalation via a filter that selects data across a tenant. A query parameter must never widen the data scope established by the caller's identity (Tenant Isolation).
- Secrets in query strings — tokens, keys, signed URLs — end up in access logs, proxy logs, browser history and
Refererheaders. Put credentials in headers (Secrets in Logs).
- "Query parameters are just strings." They are whatever your parser makes of them: strings, arrays, or nested objects, chosen by the caller.
- "Validation is for request bodies." The query string is untrusted input from the same source, and it is the one that reaches list endpoints — the expensive ones (Every Input Surface).
- "A parameterised query makes it safe." Bind parameters cover values. A sort column, a table name, a direction keyword and a
LIMITexpression are not values. - "A default limit is a limit." A default is what you use when the client says nothing. A maximum is what you enforce when the client says something.
- "Ignoring unknown parameters is lenient and kind." It converts client typos into silently wrong results, which is the failure mode that takes longest to notice.
Operating it
- A histogram of the effective
limitactually served, not the one requested. If the p99 sits at your cap, clients are asking for more than you allow and someone should know. - Rows returned and bytes serialised per request, by route template. This is the metric that catches an unbounded list endpoint before it catches you.
- A counter of rejected parameters by name and reason. It doubles as an API-usability signal and an attack signal.
- Deep-paging offsets: log or count requests with an offset above a threshold. They are almost always a script, and almost always better served by an export endpoint (Background Jobs).
- Nothing about parsing scales badly; everything about unbounded work does. At 10x traffic an uncapped
limitmoves from an occasional slow request to sustained pool pressure (Connection Pool Saturation: Waiting in Front of an Idle Database). - Offset pagination degrades with depth, not with traffic. A table growing 10x makes the last page 10x more expensive to reach while the first page stays fast, which is why the problem is invisible in testing.
- At high cardinality, filter combinations outrun your indexes: a filter set that was fine on ten thousand rows becomes a sequential scan on ten million (An Index Scan Is Not Automatically Faster).
- Caching helps only if the key space is bounded. Free-form query parameters make the key space unbounded by construction.
- A strict schema that rejects unknown parameters is the right default and it breaks clients who were sending harmless extras. Rolling it out needs a warn-then-enforce period and a metric.
- Allowlisting sort and filter fields costs a mapping table per endpoint and prevents the "just expose the query language" design that is genuinely convenient for internal consumers.
- Cursor pagination removes deep-paging cost and removes the ability to jump to page 40, which some products need (Offset Pagination: Simple, Jumpable, and Lying Under Writes versus Cursor Pagination: An Opaque Bookmark, Not a Position is a product decision).
- Hard caps mean some legitimate large request now needs a different mechanism — an export job — which is more work than raising a number.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALParse-with-caps at the boundary and allowlist anything that names a column applies to every stack and every query language.
- FRAMEWORK-SPECIFICExpress uses
qsby default, which produces arrays for repeated keys and nested objects for bracket syntax; settingquery parsertosimplerestores flat strings. Go'snet/urlalways yields[]string. Spring binds query parameters onto typed method arguments and fails the request on a conversion error. The safe assumption is that no two of these agree. - DATABASE-SPECIFICDeep
OFFSETscans and discards in Postgres and MySQL alike, but index-only scans, covering indexes and keyset pagination change the constant substantially. Whether a filter is cheap is a property of your indexes, not of the parameter.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.