GraphQL Query Depth Attacks: Limit Complexity
Cap depth and complexity per query — nested GraphQL selections let one request fan out into millions of costly resolver calls..
20+ years shipping production backend systems. Everything here is grounded in real deployments.
- ✓A GraphQL gateway you can add validation rules to
- ✓Resolver call counts or APM tracing per operation
- ✓Know your legitimate query depth distribution
- Depth attacks nest relations (friends of friends) until one query fans into millions of calls
- Enforce a max depth plus a complexity score — depth alone misses wide, shallow floods
- Persisted queries shrink the attack surface to pre-approved operations only
- Rate-limit by computed cost, not request count — one deep query out-costs 1,000 cheap ones
- Disable production introspection so attackers cannot map your costliest paths
Think of a library that fetches any referenced book on request. Ask for one book, then every book it mentions, then every book those mention — ten levels deep means millions of fetches from one polite request. Depth and complexity limits are the librarian's rule: no request may cascade past three levels or cost more than 1,000 points. Without the rule, one polite request can do the damage of a botnet.
GraphQL lets clients choose the response shape — including how deep. A single query can nest friends of friends of friends ten levels, and each level multiplies the resolver calls below it. One anonymous request can fan out into millions of database hits while your monitoring counts it as a single API call.
This is the query depth attack, GraphQL's classic denial of service. REST endpoints cap cost per route by construction; GraphQL moves that power to the client, so the server must reimpose limits deliberately. Without them, attackers (and one innocent engineer testing nesting) can flatten the database with valid syntax.
This guide builds the defense in layers. You will cap depth, score complexity by field cost, throttle by computed cost instead of request count, and lock production down with persisted queries. Each layer covers what the last one misses. All the defenses run before resolvers execute — the theme of this guide is refusing expensive work rather than surviving it. Timebox the rollout per layer; each one pays back independently.
Measure How One Query Fans Into Millions
Do the math for your own schema: branching factor to the power of depth. A friends field averaging 100 entries nested 10 deep is 100^10 theoretical calls — bounded in practice by data, but millions in any real social graph. Fragments and aliases multiply without adding depth: 50 aliases of a costly subtree cost 50x while depth stays flat.
Introspection hands attackers the map. Open introspection in production lets anyone enumerate types, find the highest-branching relations, and compose the worst-case query offline before firing once. Disable introspection in production or gate it behind admin auth — legitimate clients use build-time codegen, not live schema peeks.
Instrument before limiting: log depth, complexity estimate, and resolver call counts per operation for a week. Include the authenticated identity with each log line so quota design can distinguish partner integrations from anonymous traffic. The distribution shows your legitimate ceiling (p99 depth and cost) and exposes existing abuse. Set caps just above legitimate p99 — the data replaces arguments about what breaks. Set caps just above legitimate p99 and re-measure quarterly; organic schema growth moves p99 silently upward. Share the histogram in the platform review so caps stay team knowledge, not magic numbers.
Enforce a Max Depth Rule First
Depth validation walks the query AST and counts the deepest selection chain, rejecting anything past the cap before resolvers run. Depth 7 covers nearly all legitimate app queries (feed to comments to authors is ~4) while killing exponential nesting. Implementation is one validation rule in most server libraries — graphql-depth-limit in Node, built-in analyzers elsewhere.
Fragments complicate counting: spread fragments inline before measuring, or attackers hide depth inside named fragments. Cyclic fragments (A spreads B spreads A) must be rejected outright — they represent infinite depth, not deep depth. Test your rule against fragment-obfuscated attacks, not just inline nesting.
Roll out in log mode first: record what would reject for a week, review the hits with client teams, then enforce. Announce the enforcement date in the developer changelog so partner teams can slim their queries ahead of time. Legitimate outliers (admin dashboards, data export screens) get scoped exemptions or persisted-query carve-outs rather than a raised global cap that reopens the hole for everyone. Calendar the enforcement date visibly and send two reminders; surprise cutoffs burn partner trust that took years to build.
Score Complexity So Wide Queries Fail Too
Complexity assigns costs: scalar fields 1, objects 5-10, connections 10 plus per-child costs scaled by the requested count (first: 50 multiplies children by 50). The validator sums the query and rejects totals above budget — 1,000 is a sane start. A depth-3 query with 50 aliased subtrees scores tens of thousands and dies beside the depth-14 attack.
List arguments are the critical input. first: 100 on a connection must cost ~100x the child selection, or clients request huge pages past flat per-field costs. Multiply child complexity by the pagination argument, defaulting to the server's max page size when arguments are absent. This single rule closes the widest hole in naive scoring.
Tune costs to your resolvers' reality: a field backed by a cached lookup costs less than one firing a fresh join. Re-score after caching changes — a memoized field keeps its old price unless you update the model, silently overcharging legitimate clients. Review scoring when resolvers change — a newly-expensive field at cost 1 is an unpriced hole. Publish the cost model to partner developers so their queries pass on the first try. Include cost examples in onboarding docs; abstract budgets confuse until developers see numbers.
Rate-Limit by Cost, Not Request Count
Request-count limits treat a 2-point health check and a 900-point dashboard equally — attackers spend their quota on depth while legitimate users burn theirs on volume. Cost-based quotas fix the pricing: each operation deducts its computed complexity from a per-window budget (5,000 points per minute per token). Deep queries exhaust quota in a few calls; cheap ones flow freely.
Persist the spend per authenticated identity, falling back to IP with tighter budgets for anonymous traffic. Anonymous windows should be small — unauthenticated clients have no business running 900-point queries. Return clear quota errors with reset times so legitimate developers back off instead of retry loops amplifying the load.
Pair quotas with timeouts as a backstop. Even fairly-priced queries can pile up under concurrency — a 10-second resolver timeout plus bounded queue depths stops the pathological tail. Log quota rejections with the spend breakdown so developers see which fields ate their budget instead of guessing. Review quota dashboards weekly; a client persistently near its cap is either growing legitimately or probing your cost model. Export quota histories before incidents auto-expire; expired evidence helps nobody.
Lock First-Party Clients to Persisted Queries
Persisted queries remove arbitrary query text from production entirely. At build time, the client registers each operation and receives a hash; at runtime it sends only the hash. The server executes registered queries and rejects unknown hashes. Attackers holding a token but no registered deep query cannot express the attack — the malicious text has nowhere to go.
Adoption is gradual: enable automatic persisted queries first (server caches hashes on first use), audit the registered set for expensive operations, then switch to strict allowlists blocking unregistered hashes. Admin and tooling clients keep a narrow ad-hoc path with tight cost caps and audit logging.
Maintain the registry like code: new operations arrive via pull request with complexity estimates attached, expensive ones get review. Expire unused operations quarterly — a registry that only grows reopens attack surface through forgotten screens. Version the registry with deploys so rollbacks restore matching query sets. The registry doubles as documentation — every production query, priced and reviewed, in one list. Mirror the registry into the developer portal; discoverability prevents duplicate near-identical operations.
Harden Pagination and Timeouts as Backstops
Pagination caps bound the branching factor directly: max first/last of 50-100 per connection keeps any single level finite. Enforce server-side regardless of client arguments — silently clamp, do not error, so existing clients degrade gracefully. Relay-style connections should also cap total traversable pages per query through complexity multipliers.
Resolver timeouts bound the tail: kill any resolver exceeding N seconds and fail that branch (not necessarily the whole query) so one slow join cannot hold connections hostage. Pair with bounded DataLoader batches and database statement timeouts so the kill propagates down the stack instead of orphaning runaway queries on the database.
Test the full stack with the original attack shape: depth-14 nesting, aliased floods, giant first: arguments. Store these as a regression suite beside your unit tests — attack shapes evolve, and the suite proves each deploy still refuses them. Each must now fail in validation or quota before touching the database. Re-run the suite after every schema change — new relations reset the math. Assign suite ownership explicitly; unowned suites rot within two quarters, and the attack you already defeated returns quietly. Fund the suite like production code; unowned checks are the first thing urgency deletes, usually right before the incident they would have caught.
One Nested Query Pinned the Database at 100% for 41 Min
- Validate cost before execution, not requests after. Forty valid requests caused more damage than 40,000 cheap ones — only pre-execution scoring sees that.
- Request-count rate limiting is blind to GraphQL cost. Quotas must price computed complexity, or one deep query legally consumes the whole database.
- Persisted queries turn arbitrary nesting into an unexpressible attack. Pre-registering operations removes the malicious text entirely for first-party clients.
| File | Command / Code | Purpose |
|---|---|---|
| server.js | const depthLimit = require('graphql-depth-limit'); | Enforce a Max Depth Rule First |
| complexity.js | const { getComplexity, simpleEstimator } = require('graphql-query-complexity'); | Score Complexity So Wide Queries Fail Too |
| rate-limit.js | const QUOTA_PER_MINUTE = 5000; | Rate-Limit by Cost, Not Request Count |
| schema.graphql | type Query { | Harden Pagination and Timeouts as Backstops |
Key takeaways
Common mistakes to avoid
5 patternsRelying on depth limits alone
Leaving introspection open in production
Rate-limiting by request count
Pricing list fields flat
Forgetting fragments in depth counting
Interview Questions on This Topic
What is a GraphQL query depth attack?
Frequently Asked Questions
20+ years shipping production backend systems. Everything here is grounded in real deployments.
That's GraphQL. Mark it forged?
5 min read · try the examples if you haven't