awesome-everything RU
↑ Back to the climb

APIs

Query complexity defences: depth, cost, persisted queries

Crux DataLoader fixes database trips. Depth limits, complexity scoring, persisted queries, and alias caps stop attackers from sending query shapes that kill the server before any resolver fires.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at middle altitude — in the sky
◷ 14 min

DataLoader is live and the page loads in 80 ms. Then a security researcher sends { user { friends { friends { friends { ... } } } } } 12 levels deep. The server fires for 28 seconds and returns a 502. DataLoader did its job. The problem is the query shape itself — and DataLoader cannot help with that.

Depth limiting

A depth limiter walks the query AST and rejects documents whose nesting level exceeds a configured maximum. Typical production limit: 7–10 levels. The check runs during the validation phase — before any resolver fires, before any database is touched.

{ user { friends { friends { friends { ... } } } } }
  depth 1   depth 2   depth 3   depth 4

At depth 12 with a branching factor of 100 (each user has 100 friends), the leaf count is 100^12. Even with DataLoader batching, that is 12 batched queries each on a set of 10^22 IDs. Depth limiting kills this at parse time.

List-depth is stricter than scalar depth. A 10-level query where some levels return scalars is different from a 10-level query where every level returns a list. Some libraries (e.g. graphile/depth-limit) provide separate maxListDepth (typically 3–4).

Complexity scoring

Complexity scoring assigns a cost to each field and sums across the AST. Two styles:

  • Static weights: each field has a @cost(value: Int!) directive. The analyser walks the AST, sums costs, multiplies list-field costs by their limit arguments. Reject if sum exceeds budget (typical: 1000–10000 units).
  • Multiplicative (GitHub model): cost(parent) = cost(parent_fields) + sum(child.limit × cost(child_fields)). A query that requests 100 items at each of 5 levels costs 100^5 by this formula — far over budget, rejected at AST parse time.

GitHub caps per-query cost at 1000 and publishes the cost in extensions.cost. Shopify Storefront caps at 1000 cost/query and 1000 cost/second/IP.

Persisted queries (trusted documents)

Persisted queries replace the inline query string with a SHA-256 hash. The client registers known queries at build time; at request time it sends only the hash and variables. The server executes the stored document.

This closes the entire inline-query attack surface: arbitrary client-supplied queries are impossible. Introspection-driven enumeration, complexity attacks, alias bombs, and depth bombs are blocked at the gate.

Tradeoff: every client deploy requires a registration step. Ad-hoc tooling (Postman, browser console) no longer works against production. Public APIs that cannot constrain clients (GitHub, Shopify) keep inline queries open but add complexity scoring instead.

Alias bombs and operation batching

A single document can declare hundreds of top-level aliases for the same resolver:

q1: user(id: 1) { email }
q2: user(id: 2) { email }
...
q1000: user(id: 1000) { email }

This is one valid document, but it executes 1000 resolver calls. DataLoader collapses the database trips, but resolver-execution count is still the attacker’s leverage. Production caps: ≤20 root aliases per document, ≤5–10 operations per batch request.

DefenceWhat it stopsWhen it runs
Depth limitRecursive/deep query bombsValidation (before resolvers)
Complexity scoringCost-budget overrunsValidation
Persisted queriesAll arbitrary inline queriesBefore parsing
Alias capAlias bombsValidation
Operation batch capBatch-request amplificationBefore parsing
DataLoaderDB trip amplificationDuring resolution
GraphQL N+1 and defence: numbers
Typical depth limit
7–10 levels
List-depth recommendation
3–4
Typical complexity budget
1000–10000 units
GitHub per-query cost cap
1000
Shopify Storefront per-query cap
1000 cost units
Alias-bomb cap (typical)
≤20 root aliases
Operation-batch cap (typical)
≤5–10 operations
Quiz

Which is the strongest single line of defence for a public GraphQL API against query-complexity attacks?

Order the steps

Order the safety checks a production GraphQL server runs on an incoming query:

  1. 1 Hash lookup: is this a known persisted query? If yes, accept and execute the stored document
  2. 2 Parse and validate the document against the schema
  3. 3 Depth analysis: reject if document depth exceeds the configured maximum
  4. 4 Complexity scoring: walk the AST, sum field costs, reject if budget exceeded
  5. 5 Authorise: check that the requesting client has the right scopes for the operation
  6. 6 Execute resolvers via DataLoader-batched fetchers
Quiz

An API team enables persisted queries but leaves the inline-query endpoint open for debugging. Why is this only marginally safer than no persisted queries?

Recall before you leave
  1. 01
    What does complexity scoring do that depth limiting does not?
  2. 02
    Persisted queries block complexity attacks. What is their operational tradeoff?
Recap

DataLoader fixes the N+1 problem within resolver execution. It does nothing about query shape. Depth limits (7–10 levels) reject recursive bombs at validation time. Complexity scoring (1000–10000 budget) rejects cost-overrun documents before any resolver fires. Persisted queries close the entire inline-document attack surface by allowing only pre-registered hashes. Alias caps (≤20) and operation-batch caps (≤5–10) stop amplification attacks that defeat naive per-request rate limits. Use all layers together; each one fails closed.

Connected lessons
appears again in178
Continue the climb ↑Senior GraphQL API: scheduling contract, tenant isolation, observability
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.