Home › Web Platform › GraphQL Query Depth Attacks: Limit Complexity
Intermediate 5 min · September 23, 2026

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..

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Everything here is grounded in real deployments.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 25 min
  • ✓A GraphQL gateway you can add validation rules to
  • ✓Resolver call counts or APM tracing per operation
  • ✓Know your legitimate query depth distribution
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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
✦ Definition~90s read
What is GraphQL Query Depth Attack and Complexity Limits?

GraphQL queries are trees: each nested selection set adds a level of depth, and each field at each level triggers resolver work. Depth 10 on a friends field with 100 friends per level means 100^10 potential resolver calls — more than any database survives.

★
Think of a library that fetches any referenced book on request.

Aliases and fragments multiply it further: the same expensive subtree requested 50 times under different aliases runs 50 times. Introspection queries let attackers discover the deepest, costliest paths first.

Depth limits alone under-defend. A shallow but enormously wide query (50 aliased subtrees at depth 3) passes a depth-7 cap while costing as much as a deep one. Complexity scoring fixes this by assigning each field a cost (scalars 1, lists 10 plus children) and rejecting queries whose total exceeds a budget.

Both run in validation, before any resolver executes — rejection costs microseconds, attacks cost millions of calls.

Persisted queries close the remaining gap: clients send an operation hash, and the server executes only pre-registered queries. Arbitrary nesting becomes unexpressible — attackers cannot even submit the malicious text. Cost-based rate limiting then prices the allowed queries fairly: deep-but-permitted operations consume more quota than cheap ones, so one client cannot monopolize resolvers within the rules.

Plain-English First

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.

📊 Production Insight
A week of depth and cost histograms sets caps from evidence. Legitimate p99 plus headroom becomes the budget — no guessing, no broken clients. Re-measure quarterly; organic schema growth moves p99 silently upward, and last quarter's cap becomes this quarter's incident. Share the histogram in the platform review; visibility turns caps from magic numbers into team knowledge.
🎯 Key Takeaway
Fan-out is branching^depth — measure your schema's real distribution before capping.

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.

server.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
const depthLimit = require('graphql-depth-limit');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(7)],
});

// Attack query (depth 14) now fails validation in microseconds.
Try it live
📊 Production Insight
Log-mode rollout first: a week of would-reject logs tunes the cap to evidence and gives client teams warning before enforcement.
🎯 Key Takeaway
Cap depth around 7 in validation — cheap to enforce, kills exponential nesting.

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.

complexity.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const { getComplexity, simpleEstimator } = require('graphql-query-complexity');

async function checkBudget(schema, document, variables) {
  const complexity = getComplexity({
    schema,
    query: document,
    variables,
    estimators: [simpleEstimator({ defaultComplexity: 1 })],
  });
  if (complexity > 1000) {
    throw new Error('Query complexity ' + complexity + ' exceeds budget 1000');
  }
}
// Depth-3 aliased flood scores ~40000: rejected before resolvers run.
Try it live
📊 Production Insight
Multiply child costs by pagination arguments — flat per-field costs let first: 10000 slip past any budget. The multiplier is the whole defense for lists.
🎯 Key Takeaway
Budget total complexity (~1,000) with list costs scaled by requested counts.

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.

rate-limit.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
const QUOTA_PER_MINUTE = 5000;

async function checkQuota(token, complexity, quotaStore) {
  const spent = await quotaStore.spend(token, complexity, 60);
  if (spent > QUOTA_PER_MINUTE) {
    throw new Error('Complexity quota exceeded for token. Retry after 60s.');
  }
}
// 40 attack queries at ~40000 pts: blocked after the 1st request.
Try it live
⚠ Count Limits Cannot See Cost
100 requests per minute allows one hundred 900-point monsters. Price quotas in complexity points or depth attacks stay legal under your limits.
📊 Production Insight
Anonymous budgets must be tight — unauthenticated 900-point queries are never legitimate. Small anonymous quotas close the cheapest attack path.
🎯 Key Takeaway
Deduct computed complexity from per-token budgets; backstop with timeouts.

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.

📊 Production Insight
APQ-then-strict is the safe migration: cache-and-learn first, allowlist enforcement after the operation set is known and priced.
🎯 Key Takeaway
Persisted queries make malicious nesting unexpressible — registry-guard production clients.

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.

schema.graphqlGRAPHQL
1
2
3
4
5
6
7
# Server clamps pagination: clients asking first: 10000 get 50
type Query {
  friends(first: Int = 20): FriendConnection
}
# Depth-14 nesting: rejected by depth rule.
# 50 aliased subtrees: rejected by complexity budget.
# Giant pages: clamped to 50, then priced fairly.
📊 Production Insight
Clamp, do not error, on oversized pages — graceful degradation keeps old clients working while bounding the branching factor.
🎯 Key Takeaway
Clamp page sizes, time out resolvers, and re-attack your own API after schema changes.
● Production incidentPOST-MORTEMseverity: high

One Nested Query Pinned the Database at 100% for 41 Min

Symptom
At 15:20 database CPU pinned at 100% and legitimate app queries started timing out — 1,900 user-facing errors over 41 minutes. API request rates looked normal (one client, ~40 requests), but each request nested friends 14 levels deep. Connection pools saturated and the on-call paged twice before anyone correlated the single token to the outage.
Assumption
The team blamed a runaway data pipeline and spent 15 minutes checking ETL jobs. Then they blamed a DDoS and engaged the WAF, which found nothing — 40 requests from one authenticated token does not trip volumetric alarms. The query text was valid GraphQL, so gateway logs showed zero errors.
Root cause
No depth or complexity validation existed on the gateway. One token submitted a 14-level nested friends query (valid syntax, ~2KB text) that fanned into roughly 3.8 million resolver calls per execution. Retried 40 times by a scraping script, it kept the database at 100% CPU for 41 minutes. Request-count rate limiting never fired because 40 requests looked harmless.
Fix
They deployed a max-depth-7 rule plus a complexity budget of 1,000 points with per-field costs, which the malicious query exceeded 40-fold. Persisted queries became mandatory for the mobile app, and rate limiting switched to cost-based quotas. The token owner (a partner runaway scraper) got scoped credentials and a documented query-cost guide.
Key lesson
  • 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.
Production debug guideFive steps — detect the fan-out, cap it, price it, lock it down.5 entries
Symptom · 01
Database saturates while API request rates look normal
→
Fix
Log resolver call counts and max selection depth per operation. A handful of requests each spawning hundreds of thousands of resolver calls — with depth above 7-10 — confirms a depth attack. Identify the token and query hash, then block the hash immediately while you build permanent limits.
Symptom · 02
No depth validation exists on the gateway
→
Fix
Add a max-depth validation rule (7 is a common starting budget) that rejects deeper queries in validation before resolvers run. Deploy with logging first to measure legitimate-query rejections, tune the number, then enforce. Verify the attack query now fails validation in microseconds.
Symptom · 03
Wide shallow queries still hurt after a depth cap
→
Fix
Add complexity scoring: scalars cost 1, object fields 5-10, list fields 10 plus child costs multiplied by first/last arguments. Set a budget (e.g. 1,000) and reject above it. Re-test with aliased wide queries — depth-3 floods 40x over budget must now fail.
Symptom · 04
One client monopolizes resolvers within the limits
→
Fix
Switch rate limiting from request counts to computed complexity per time window — e.g. 5,000 complexity points per minute per token. Cheap queries stay unlimited in practice; deep permitted ones consume quota fast. Test that the attack token exhausts quota in 2-3 requests.
Symptom · 05
First-party clients keep inventing expensive queries
→
Fix
Require persisted queries for production mobile and web clients: register approved operations at build time and reject unknown hashes. Attackers and runaway scripts can no longer submit arbitrary nesting. Keep a small allowlisted ad-hoc path for tooling with tight cost caps.
Depth Defenses Compared
Root CauseHow to ConfirmFixPrevention
Deep nested selectionsDepth 10+ with resolver counts explodingMax-depth validation rule (~7)Log-mode rollout; scoped exemptions only
Wide aliased floodsShallow depth but huge resolver countsComplexity budget with list multipliersPrice pagination args; publish cost model
Quota-blind rate limitsFew requests saturating the databaseCost-based quotas per token windowTight anonymous budgets; quota error clarity
Arbitrary client queriesNovel expensive shapes keep appearingPersisted queries for first-party clientsRegistry review with complexity estimates
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
server.jsconst depthLimit = require('graphql-depth-limit');Enforce a Max Depth Rule First
complexity.jsconst { getComplexity, simpleEstimator } = require('graphql-query-complexity');Score Complexity So Wide Queries Fail Too
rate-limit.jsconst QUOTA_PER_MINUTE = 5000;Rate-Limit by Cost, Not Request Count
schema.graphqltype Query {Harden Pagination and Timeouts as Backstops

Key takeaways

1
Fan-out is branching^depth
one query can cost millions of calls.
2
Validate in the execution path
depth caps kill exponential nesting.
3
Complexity budgets with page multipliers catch wide shallow floods.
4
Price rate-limit quotas in complexity points, not requests.
5
Persisted queries make malicious shapes unexpressible.
6
Clamp pages, time out resolvers, and re-attack after schema changes.

Common mistakes to avoid

5 patterns
×

Relying on depth limits alone

Symptom
Depth-3 aliased floods sail past the depth-7 cap and saturate the database — the rule measures depth, not cost.
Fix
Add complexity scoring with pagination multipliers beside the depth cap. Both run in validation.
×

Leaving introspection open in production

Symptom
Attackers map your costliest paths offline and compose the worst-case query before firing a single request.
Fix
Disable introspection in production or gate it behind admin auth. Clients use build-time codegen.
×

Rate-limiting by request count

Symptom
40 devastating requests never trip a 100-per-minute limit while legitimate volume users get throttled — pricing is backwards.
Fix
Deduct computed complexity from per-token budgets so deep queries cost quota fairly.
×

Pricing list fields flat

Symptom
first: 10000 costs the same as first: 10 in your scorer, so giant pages bypass the whole budget.
Fix
Multiply child complexity by pagination arguments, defaulting to max page size when absent.
×

Forgetting fragments in depth counting

Symptom
Attackers hide depth-14 nesting inside named fragments that naive counters measure as depth 2.
Fix
Spread fragments before measuring and reject cyclic fragments outright. Test rules against obfuscated shapes.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is a GraphQL query depth attack?
Q02SENIOR
Why is a max-depth rule not enough on its own?
Q03SENIOR
How should pagination arguments affect complexity?
Q04SENIOR
What do persisted queries buy you against DoS?
Q05SENIOR
How do you rate-limit GraphQL fairly?
Q01 of 05JUNIOR

What is a GraphQL query depth attack?

ANSWER
A client nests relations deeply (friends of friends, 10+ levels) so one small query fans into millions of resolver calls. REST caps cost per route; GraphQL lets clients choose depth, so servers must cap it. Without validation, one request can saturate the database.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What depth cap should I start with?
02
Will complexity scoring not slow every request?
03
Should I disable introspection?
04
Do persisted queries break GraphiQL-style tooling?
05
How do aliases multiply attack cost?
06
Can timeouts replace complexity limits?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Everything here is grounded in real deployments.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's GraphQL. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
GraphQL Cannot Return Null for Non-Nullable Field
3 / 4 · GraphQL
Next
Apollo Client Cache Not Updating After Mutation
→