GraphQL Query Complexity DoS: When Nested Fields Exhaust the API
GraphQL lets clients ask for exactly the graph they want. That flexibility is also a load amplifier: one HTTP request can explode into thousands of resolver calls. Complexity / cost attacks sit between “fun nested query” and “I knocked over staging.”
Programs vary on DoS payouts. Still worth documenting when unauthenticated or low-privilege users can burn CPU, DB, or worker queues with a tiny payload. A 2 KB query that triggers 50k row fetches is the story — not “GraphQL is slow.”
Cost shapes that hurt
- Deep nesting:
user { friends { friends { … } } }until the stack or DB melts - Aliases: same heavy field repeated under
a1:,a2:, … past naive depth checks - List × list: paginated connections without caps (
posts { comments { reactions } }) - Batching: array of operations in one POST where each op is already expensive
- Circular fragments: fragment spreads that multiply field selection sets
- Persisted-query bypass: if only persisted queries are “safe,” check whether arbitrary queries still work on another path
Introspection off does not stop complexity abuse. You only need one known heavy field from the SPA or a prior recon note.
Safe measurement (do not nuke prod)
Prefer the program’s staging graph. Cap your tests: start with depth 3–5, small page sizes, one alias batch, then stop when latency or error rates climb. Watch your own client timeouts — that is evidence enough.
Record:
- Query text (trimmed)
- Response time vs a shallow control query
- Auth level (anon vs user)
- Whether rate limits or persisted-query mode blocked you
- Approximate response size growth vs request size
If production is the only target, ask the program or keep tests minimal and non-repeated. One carefully bounded proof beats a thread of retries that look like an outage.
Resolver traps worth naming
N+1 patterns hide behind GraphQL convenience. A field like author { lastLoginIp } on a list of 500 posts may fire 500 auth lookups. Multiplying that with aliases is how a “simple” query becomes a DB incident. Mention ORMs and DataLoader absence when you see repeated identical queries in timing side channels — but prove with your own numbers, not speculation.
What is not a finding
- You can write a slow query that also requires an admin token and hits an intentional report job
- Complexity already rejected with a clear
COMPLEXITY_LIMIT_EXCEEDED - CDN rate limits that stop you after three tries (mention residual risk, but severity is lower)
- Introspection enabled on staging only, with no cost issue on the public API
A solid report shows unauthenticated or normal-user cost that scales faster than request size — bytes in vs work out.
Defenses to name
- Query depth limits and static complexity scoring before execution
- Alias and batch caps
- Timeouts and per-resolver budgets
- Persisted queries / allowlists for public APIs
- Pagination hard caps server-side, not only in the UI
- Cost-aware rate limits (points per minute, not only requests)
PortSwigger’s GraphQL academy topic covers DoS alongside authorization issues. Lead with measured cost, not a threat of “infinite recursion.” Triagers respond to numbers.