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: friendanda2: friendare 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:
- 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.
- 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
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?
2. A query selects 500 aliased sibling fields at the root. What is its depth, and does a depth limit of 5 block it?
3. Where does a depth counter count a fragment’s fields?
4. What can a custom validation rule do that a depth limit cannot?
Flashcards
Question
Query depth
Click to reveal answer
Answer
Maximum nesting of selection sets from the operation root; counted with fragments resolved at their spread site.
Question
Why reject in validation
Click to reveal answer
Answer
Validation runs on the AST before execution, so abusive documents cost parse time only — no resolvers, no data access.
Question
Alias bomb
Click to reveal answer
Answer
Thousands of aliased siblings at depth 1 — defeats depth limits, caught by a max-fields rule or cost budget.
Question
Depth vs cost
Click to reveal answer
Answer
Depth is structural and free; cost weights fields by list size and resolver expense. Use both.
Question
Custom rule use
Click to reveal answer
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.