Your list endpoint forwards query-string filter parameters into the data layer, and clients may name any field and any operator. What goes wrong in production, and how do you constrain the surface without crippling the API?
answer
- filter = predicate oracle, leaks invisible fields
- silent-ignore of a scoping param = data exposure
- attacker chooses your query plan
- parameterization protects values, not identifiers
- declare field → operators → type → cost class
basics
~20 sArbitrary filters leak internal and unauthorized fields, enable expensive unindexed scans, and can inject query fragments. Constrain with an explicit per-resource field×operator whitelist, typed value coercion, cost caps, and 400 on anything not allowed — never silent ignore.
solid answer
~50 sThree failure classes. **Exposure.** Arbitrary filter fields turn a list endpoint into an oracle: filtering on a field the caller can't see still tells them who matches it (`?internal_risk_score[gt]=90`), and filter names leak the internal schema. Worse is the *silent-ignore* bug — dropping an unrecognized scoping parameter returns everyone's rows. **Cost.** Any field becomes filterable, including unindexed and computed ones, so one caller can turn a cheap endpoint into a full scan. Cost is now attacker-controlled. **Injection.** Field names and operators concatenated into a query fragment are an injection vector that parameterized *values* don't protect against. The constraint is a declared, per-resource matrix: field → allowed operators → value type → whether it's indexed. Everything flows from it: reject unknown field/operator with `400` and a body naming it, coerce and range-check values by type, require at least one selective filter on large collections, cap terms and free-text `contains`, apply per-query timeouts, and treat the matrix as versioned public contract rather than an emergent property of the ORM.
code
json · 10 lines{
"resource": "orders",
"filters": {
"status": { "ops": ["eq", "in"], "type": "enum", "values": ["open","paid","void"], "cost": "indexed" },
"created_at": { "ops": ["gte", "lt"], "type": "date-time", "cost": "indexed" },
"total": { "ops": ["gte", "lte"], "type": "integer", "min": 0, "cost": "indexed" },
"note": { "ops": ["contains"], "type": "string", "maxLength": 64, "cost": "expensive" }
},
"rules": { "max_terms": 8, "require_selective_predicate": true }
}go deeper
Say that filterable fields and operators must be an explicit allowed list, and that unknown ones should return 400 rather than being dropped.
Add value coercion by type, injection via identifiers rather than values, and why silent-ignore is a data-exposure bug.
Own the whole surface: cost classes, required selective predicates, statement timeouts, per-filter-shape metrics, and authorization scope applied server-side regardless of client filters.
Make the filter matrix a first-class, versioned part of the API contract with a deprecation path, generated validation/docs/tests from one declaration, and an estate-wide policy on which resources may expose expensive filters at all.
## Why passthrough filtering is a real incident class "Whatever the client sends, we pass to the query builder" is convenient and appears in a lot of internal APIs. It fails in three distinct ways. ### 1. Information exposure A filter is a *predicate oracle*. Even if the field never appears in the response body, being able to filter on it lets a caller binary-search values: `?internal_risk_score[gt]=90` returns the high-risk customers, and repeated queries recover a per-record value one bit at a time. Fields the API deliberately hides — internal scores, moderation flags, soft-delete markers, other tenants' identifiers — must not be filterable either. Filter names also leak schema. Error messages like "unknown column `users.password_reset_token`" hand an attacker a map. Whitelist errors should name only fields *from the whitelist*, never echo the storage layer's message. The worst variant is **silent ignore**. A client scopes a list with `?owner_id=42`; the server does not recognize the parameter and drops it; the response contains every owner's data. It looks like a UX nit and behaves like a broken-access-control incident. Fail closed: unknown filter → `400`. ### 2. Attacker-controlled cost With arbitrary filters, the caller chooses your query plan. `?notes[contains]=a` over a hundred million rows, or a filter on a column with no index, converts a millisecond endpoint into a scan that saturates the database for everyone. Rate limiting by request count does not help, because the requests are few and each is enormous. This is the practical reason to distinguish, inside the whitelist, which fields are *cheap* (indexed, selective) and which are *expensive* (free-text, computed, joined). Mitigations, in layers: mark each filterable field as indexed or not; require at least one indexed/selective predicate (or a bounded time range) on large collections; cap the number of filter terms per request; enforce a statement timeout so a bad query dies rather than piles up; run heavy list traffic on a read replica; and monitor list-endpoint latency **by filter shape**, not just by route, so one pathological shape is visible instead of averaged away. ### 3. Injection Parameterization protects *values*, not identifiers. A field name or an operator concatenated into a query string is an injection point in SQL, and in document stores the equivalent is passing a client-supplied operator object straight into the query — the classic NoSQL operator injection where a value of `{"$ne": null}` turns an equality check into "anything". The defense is not escaping: it is mapping. The client's `created_at` looks up a server-owned column reference; the client's `gte` looks up a server-owned operator. Anything not in the map never reaches the query layer. ## The whitelist as an artifact Make it a declaration, per resource, not scattered `if` statements: - field name (public name, mapped to internal column/path) - allowed operators for that field - value type and constraints (enum members, max string length, allowed date range) - cost class (indexed / unindexed / joined / computed) - whether the field requires a particular authorization From that single declaration you generate validation, the query translation, the documentation, the `400` error's `supported` block, and tests. Anything not declared is unsupported — which also means adding a filter is a deliberate, reviewable contract change rather than something that appears because a column was added. ## Authorization interacts with filtering Filtering must be applied *on top of* the caller's authorized scope, never as a substitute for it. The server-side tenant/owner predicate is appended unconditionally, independent of the query string; the client's filters can only narrow further. And note the corollary: a filter on a field the caller may not read should be rejected, because narrowing by an invisible field still reveals it. ## Values, not just fields Coerce by declared type before use: a date field gets a parsed date, an enum field gets a member check, a numeric field a range check. Bound list-valued operators (`status[in]=` with 10 000 members is a cost attack) and bound string lengths for `contains`. Reject with `400` and a specific, safe message. ## What not to do Don't try to make everything filterable "for flexibility" and defend it with a query cost analyzer you haven't built. Don't accept raw predicate fragments (`?where=`) under any framing. Don't rely on the client SDK to restrict what is sent. And don't respond to abuse by removing filters clients already depend on — that is why the matrix should be treated as public contract with a deprecation path from the start. ## Strong answer, compressed "Filtering is a declared capability, not a passthrough. Per resource I declare field → operators → type → cost class; unknown field or operator is a 400 naming it, never a silent drop, because a dropped scoping filter returns everyone's rows. Values are coerced and bounded. Expensive fields are marked, large collections require a selective predicate, statement timeouts and per-filter-shape latency metrics catch the rest. Authorization scope is appended server-side regardless of what the client filters on."
- Parameterized queries stop SQL injection. Why is a filter whitelist still needed?Parameter binding protects values, not identifiers: a column name, a table alias, or a sort direction cannot be bound and must be concatenated, so a client-supplied field name is an injection point regardless. Mapping the client's public field name to a server-owned column reference removes the concatenation entirely. The whitelist also covers the non-injection risks — exposure of hidden fields and attacker-chosen query cost.
- How would you detect that one caller's filter shape is degrading a shared list endpoint?Emit the normalized filter shape — field and operator names, no values — as a metric dimension alongside latency and rows examined, and tag it with the API key. Route-level averages hide a rare, enormous query behind millions of cheap ones. With per-shape metrics you can see the offending shape, cap or de-index it, and talk to the specific caller.
- Should the API reject a filter on a field the caller is not authorized to read?Yes, with the same `400`/`403` treatment as an undeclared field. Narrowing a result set by an invisible field still reveals that field's values through the membership of the result, so a read restriction that does not extend to filtering is not a restriction at all.
Passthrough filtering is a library where readers may specify any index to search on — including the confidential borrower-history index — and the librarian must walk every shelf if no index exists.
saying these in an interview costs you the question
- "Parameterized queries mean field names are safe too"
- Ignoring unknown filter parameters instead of rejecting them, so a dropped scoping filter widens the result set
- Echoing the storage engine's error message back to the client, leaking schema
- Treating rate limiting by request count as protection against expensive filters
- Letting every column become filterable automatically as the schema grows, with no review