AppSec · Sep 19, 2026 · 7 min read

GraphQL query complexity: when the schema becomes a DoS surface

A GraphQL schema with no depth limit, no complexity budget, and no alias cap is a documented DoS surface - confirming the gap uses a graduated timing probe, not a flood.

GraphQL's composable query model is its defining feature: callers pick exactly the fields they need and traverse relationships in a single round trip. That flexibility is also an unenforced attack surface. A schema with no depth limit, no complexity budget, no alias cap and no batch-size restriction is a documented map of DoS vectors, all reachable from the same single endpoint. None of these controls are enabled by default in any major GraphQL server.

01Nested queries and resolver fan-out

The resolver pattern is the root of the exposure. Every field a caller requests triggers a resolver function, and a nested field triggers one per parent result. A three-level query that fetches 10 users, their 5 orders each, and the 3 line items per order resolves 1 + 10 + 50 + 150 = 211 resolver calls from one HTTP request. At depth 8 against a schema with cyclic types - User references Order, which references Product, which references Reviewer (a User) - the theoretical resolver count is exponential and the only thing slowing it down is the database connection pool exhausting first.

query { depth 0 - 1 resolver users { depth 1 - N resolvers orders { depth 2 - N x M resolvers lineItems { depth 3 - N x M x K resolvers product { reviewer { depth 4-5 - exponential orders { lineItems { } } } } } } }
A cyclic type graph with no depth limit allows one HTTP request to multiply into an exponential resolver chain. N=10, M=5, K=3 produces 211 resolver calls by depth 3.

02Alias flooding and JSON batching

Depth limits are not the only amplification path. GraphQL aliases let a caller invoke the same field under a different name in the same query, and each alias resolves independently. A query carrying 100 aliased calls to an expensive search resolver runs 100 resolver calls while the HTTP layer sees one request and a simple rate limiter counts one hit. Batching - sending a JSON array of operations in the POST body rather than a single object - achieves the same amplification: a batch of 20 full queries counts as one request per most rate-limiting implementations. The two techniques compose: 20 batched queries, each carrying 50 aliases, deliver 1000 independent resolver invocations from a single HTTP request.

alias-flood-probe.graphqlgraphql
# Alias flood probe - 50 aliased resolver calls in one query
query AliasFl_62615533 {
  a01: searchProducts(q: "probe") { id title }
  a02: searchProducts(q: "probe") { id title }
  # ... a03 - a49 omitted for brevity ...
  a50: searchProducts(q: "probe") { id title }
}
# Safe: nonce query string returns no real records.
# Confirming artifact: latency vs single-alias baseline.

03Confirming the gap without flooding production

The confirming probe for a missing depth limit follows the same graduated-oracle discipline as any other boundary test. Start with a depth-3 query and record baseline response time. Repeat at depth-6, then depth-8. A latency curve that grows faster than linear - and a response at depth-8 that returns 200 OK rather than an error carrying a complexity-exceeded code - is in-band evidence that no depth gate is enforced. No flood is needed. The confirming artifact is the latency ratio and the absent error; a depth-8 probe against a staging dataset is sufficient to show the growth curve without exhausting a production pool. For alias flooding, a 50-alias probe versus a single-alias baseline pins the per-alias overhead. Neither probe writes data or reads anything a legitimate caller could not already access.

Reachable - HighNo depth limit - /graphql
POST /graphql depth-3 probe ──► 200 OK 58 ms
POST /graphql depth-6 probe ──► 200 OK 430 ms
POST /graphql depth-8 probe ──► 200 OK 3 210 ms
No errors[].extensions.code field in any response
Latency ratio depth-8 / depth-3: 55x. Absence of a complexity-exceeded error at depth-8 confirms no depth or complexity gate is active. Rated high (confirmed resource exhaustion via latency evidence); would escalate to critical on a confirmed OOM kill or process restart at depth 10+.

04Controls: independent layers, all off by default

No single control closes the full surface. Depth limits stop nested fan-out but leave wide shallow queries open. Complexity budgets handle both depth and field count but must include aliases explicitly or alias flooding bypasses the budget. Batch-size limits cap the array multiplier but do not constrain individual query cost. Persisted queries - an allowlist of pre-registered query documents - remove arbitrary query composition entirely and close all four vectors at once, at the cost of requiring registration for every legitimate client query. In the graphql-js and Apollo Server ecosystem each control is a separate install: graphql-depth-limit, graphql-validation-complexity and a custom batch middleware are all opt-in. Strawberry (Python) and Ariadne expose depth and complexity via native extensions. Hasura enforces per-role allow-lists at the engine layer, which is equivalent to persisted queries for that surface.

ControlVector blockedBypass path if omittedDefault in major servers
Depth limitNested fan-outWide shallow queries, alias floodsOff
Complexity budgetDepth + field countAlias flooding if aliases excluded from costOff
Alias limitHorizontal amplificationJSON batch arrayOff
Batch size limitArray-per-request amplificationSingle high-cost queryOff
Persisted queriesAll of the aboveOnly if allowlist is incompleteOff (opt-in)
55x
latency ratio depth-8 vs depth-3
5
independent DoS controls, all off by default
1
endpoint exposes every query-based vector

GraphQL's single-endpoint design concentrates all of these vectors at one URL. A network-layer rate limiter that counts requests per IP does not stop an authenticated user from sending a single depth-10 query; complexity analysis at the schema layer is the control that does. The finding is rated on confirmed latency evidence rather than theoretical fan-out, because actual exposure depends on schema shape: a schema with no cyclic relationships and shallow resolver chains has a different real ceiling than one where every type references every other. The probe measures real server behavior against the specific schema in front of it - not a worst-case calculation from a type graph.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →