Skip to content
intermediate Phase 14 · Performance, Caching, and Production Hardening

Depth Limiting and Query Validation

Rejecting overly nested queries before they reach resolver execution.

40m
1 problems
Topic Progress 0%

Why Nesting Is the Attack Surface

Why Nesting Is the Attack Surface

REST endpoints have fixed shapes: /friends returns one page and stops. GraphQL lets a client decide the shape at request time, and a nested list query multiplies at every level:

query {              # depth 1
  user(id: "u1") {   # depth 2
    friends {        # depth 3
      friends {      # depth 4
        friends {    # depth 5 — 5 levels of list resolvers
          name
        }
      }
    }
  }
}

If each user has 50 friends, depth 5 with an implicit page size of 50 is up to 50⁴ leaf rows before filtering — millions of resolver invocations and the database joins behind them, all from a 120-byte request. The attacker does not need credentials beyond a valid token, does not need to know your schema beyond introspection, and never touches a field you flagged as expensive individually. The cost is structural: it comes from nesting, which no per-field rate limit sees.

Depth is defined as the maximum nesting of selection sets, counted from the operation's root. The query above has depth 5. Counting has three rules that trips people up:

  • Aliases do not add depth. a1: friend and a2: friend are siblings, so a document with 2,000 aliases is still depth 1 — and that is exactly the query a depth limit waves through.
  • Fragments are counted where they are spread, not where they are defined. A one-line fragment spread at level 6 contributes level 6, even though its definition sits at the top of the file.
  • Introspection is a query too: __schema { types { fields { type { ... } } } } is legitimately deep, so decide deliberately whether introspection is exempt or banned outright on a public endpoint.

A depth limit turns an exponential execution problem into a string comparison performed before execution starts.

Rejecting Before Execution

Rejecting Before Execution

GraphQL servers run three phases per request: parse, validate, execute. Validation is where every rule in the spec runs, and it is pure — no resolvers are invoked, no data is touched, so a rejected query costs you microseconds of AST walking. That is why depth limits belong here rather than in a resolver guard.

import depthLimit from 'graphql-depth-limit';
import { ApolloServer } from '@apollo/server';

const server = new ApolloServer({
  schema,
  // runs with the standard spec rules; exceeding maxDepth is a validation error
  validationRules: [depthLimit(8)],
});

The library walks the operation and reports the first selection set that nests past the limit as a normal GraphQL validation error, so clients get it in errors[] with a path and you get it in metrics without instrumenting anything.

Two things to verify with your own tests rather than trusting the default:

  1. Fragment spreads. Write a document whose only depth comes from a spread fragment and assert the rule counts it. A rule that measures fragment definitions instead of usages can be evaded by moving the recursion into a fragment body.
  2. Which operations are walked. The rule must apply to queries, mutations, and subscriptions alike; a rule registered only for queries leaves mutation documents unrestricted.

Custom rules follow the same shape. This one caps total selected fields, which is the companion guard depth cannot provide:

import { GraphQLError } from 'graphql';

export function fieldCountLimit(max) {
  return (context) => {
    let count = 0;
    return {
      Field() {
        if (++count > max) {
          context.reportError(
            new GraphQLError('Operation selects too many fields', {
              extensions: { code: 'QUERY_TOO_COMPLEX' },
            }),
          );
        }
      },
    };
  };
}

Because validation rules see the whole document, the counter also sees fields inside fragment definitions — including definitions the operation never spreads. That conservative overcount is the right bias for a guard: better to reject a bloated document than to execute it.

What Depth Limits Do Not Catch

What Depth Limits Do Not Catch

A depth limit of 10 still permits a query at depth 2 with 3,000 aliased fields:

query {
  a1: product(id: "p1") { price }
  a2: product(id: "p2") { price }
  # ... 2,998 more aliases
}

Depth: 1. Resolver invocations: 3,000. The aliases exist precisely because depth counts nesting, and aliases are flat. This is the alias bomb, and it is why production setups layer rules rather than shipping one:

Rule Catches Misses
Max depth exponential nesting wide flat queries
Max fields alias bombs a single brutally expensive field
Banned fields __schema, internal admin fields cost concentrated in allowed fields
Cost budget everything above, weighted by list size nothing a static analysis can see

Depth and field count together give you a cheap structural ceiling: both are O(document size) to compute and both run in validation. But neither knows that shippingEstimate calls a pricing service while id reads a column, and neither knows a list will return 10,000 rows instead of 10. That is the gap cost analysis fills — assign weights, multiply by list-size estimates, and cap a total budget. See cost analysis and budgets for the scoring model that turns these structural guards into a capacity guard.

The final layer is what you expose at all. A custom rule that rejects __schema or a specific field by name is one line of validation logic and removes an entire reconnaissance path from a public API — depth limits protect your database, field bans protect your surface area.

Depth limits are also the wrong tool for legitimate deep documents. Relay-style pages are shallow by construction (connection → edges → node), but a client rendering a deeply nested approval chain may genuinely need depth 12. Set the threshold from production traffic: measure the depth distribution of real operations, pick a limit above the 99.9th percentile, and let the rejects tell you whether a client needs a different query shape rather than a higher ceiling.

Practice Problems

0 / 1 solved
Compute Depth and Decide Whether to Reject
Validation Rules

Compute the depth of the operation below (fragments counted at their spread site, aliases counted as siblings) and decide whether a server configured with depthLimit(5) accepts or rejects it. Then say what a depth-only rule would miss about the second document.

Example:

Input: fragment Names on User { friends { name } } query Shallow($id: ID!) { user(id: $id) { ...Names } # spread at depth 2 } query Wide($id: ID!) { u1: user(id: $id) { id } u2: user(id: $id) { id } # ... 998 more aliases, each with { id } }

Output: Shallow: depth 3 → accepted (3 <= 5) user = 1, friends = 2, name = 3 (fragment counted at its spread) Wide: depth 1 → accepted by depthLimit(5) but 1,000 aliased fields × 1 selection = ~2,000 field visits → needs fieldCountLimit or a cost budget, not a depth rule

The fragment definition itself contributes nothing; its fields execute where it is spread, so friends sits at depth 2 and name at depth 3. The wide document is genuinely depth 1 — siblings never nest — which is why depth alone is not a capacity guarantee.

Optimal Solution — O(N) for N AST nodes (O(N²) worst case with self-referencing spreads — GraphQL forbids them, so the cycle case cannot occur) time, O(F) for F fragment definitions plus recursion depth space

Walk selection sets recursively, carrying the current nesting level; resolve fragment spreads inline and take the maximum level reached anywhere in the document.

import { visit } from 'graphql';

export function computeDepth(document) {
  const fragmentByName = new Map();
  visit(document, {
    FragmentDefinition: (node) => fragmentByName.set(node.name.value, node),
  });

  const walk = (selections, level) => {
    let max = level;
    for (const sel of selections) {
      if (sel.kind === 'Field') {
        if (sel.selectionSet) max = Math.max(max, walk(sel.selectionSet.selections, level + 1));
      } else if (sel.kind === 'InlineFragment') {
        max = Math.max(max, walk(sel.selectionSet.selections, level));
      } else if (sel.kind === 'FragmentSpread') {
        const def = fragmentByName.get(sel.name.value);
        if (def) max = Math.max(max, walk(def.selectionSet.selections, level));
      }
    }
    return max;
  };

  let depth = 0;
  visit(document, {
    OperationDefinition: (node) => {
      depth = Math.max(depth, walk(node.selectionSet.selections, 1));
    },
  });
  return depth;
}

Edge Cases:

  • An operation spreading the same fragment twice is counted twice, once per spread
  • Inline fragments on an interface contribute their own nesting level
  • A document with only a fragment definition and no operation has depth 0 — nothing executes

Quiz

1. At which phase does a depth limit reject an abusive query?

Question 1 options

2. A query selects 500 aliased sibling fields at the root. What is its depth, and does a depth limit of 5 block it?

Question 2 options

3. Where does a depth counter count a fragment’s fields?

Question 3 options

4. What can a custom validation rule do that a depth limit cannot?

Question 4 options

Flashcards

Question

Query depth

Answer

Maximum nesting of selection sets from the operation root; counted with fragments resolved at their spread site.

Question

Why reject in validation

Answer

Validation runs on the AST before execution, so abusive documents cost parse time only — no resolvers, no data access.

Question

Alias bomb

Answer

Thousands of aliased siblings at depth 1 — defeats depth limits, caught by a max-fields rule or cost budget.

Question

Depth vs cost

Answer

Depth is structural and free; cost weights fields by list size and resolver expense. Use both.

Question

Custom rule use

Answer

Ban specific fields (e.g. __schema), enforce required arguments, or count fields across the whole document.

Revision Notes

Key Takeaways

  • 1. Depth limits run in validation, so a rejected attack costs microseconds and zero resolver work
  • 2. Aliases do not add depth; fragments count where spread — both facts decide whether your rule is evadable
  • 3. Layer depth + field count + cost budget, and set thresholds from measured production distributions

Interview Tips

  • • Compute depth out loud on an example with a fragment spread and two aliases — interviewers are testing the edge rules, not the happy path
  • • State the failure mode of each guard before naming it: exponential nesting → depth, flat wide → field count, weighted expense → cost

Cheat Sheet

Pipeline: parse → validate → execute. Depth rules live in validate.

Setup

const server = new ApolloServer({ schema, validationRules: [depthLimit(8)] });

Counting rules

  • Depth = max selection-set nesting from the operation root
  • Aliases are siblings → add width, never depth
  • Fragment fields count at the spread site
  • Apply to queries, mutations, and subscriptions

Layered guards

Guard Catches
max depth exponential list nesting
max fields alias bombs
banned field rule __schema / internal fields
cost budget weighted list + resolver expense

Tune the limit above the 99.9th percentile of measured production depth.

Continue Learning