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.
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.
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 - 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.
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.
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.
| Control | Vector blocked | Bypass path if omitted | Default in major servers |
|---|---|---|---|
| Depth limit | Nested fan-out | Wide shallow queries, alias floods | Off |
| Complexity budget | Depth + field count | Alias flooding if aliases excluded from cost | Off |
| Alias limit | Horizontal amplification | JSON batch array | Off |
| Batch size limit | Array-per-request amplification | Single high-cost query | Off |
| Persisted queries | All of the above | Only if allowlist is incomplete | Off (opt-in) |
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.