GraphQL hardening: query cost, depth and federation
A public GraphQL endpoint lets the client compose the query, so one request can fan out to millions of resolver and DB calls — a DoS vector REST does not have. Harden with depth limits, query-cost analysis, and persisted queries; scale ownership with Apollo Federation.
The GraphQL endpoint had been public for eight months. No auth needed for the read graph — it was meant to power a partner integration, and the team was proud of how flexible it was. At 02:14 the on-call phone went off: API p99 had gone vertical, Postgres connections were pinned at max, and the box was swapping. There was no spike in request count. There was one request. Someone — a scraper, a curious bot, it never mattered — had sent a single query: user { friends { friends { friends { posts { author { friends } } } } } }. The schema happily resolved it, and each level multiplied the fan-out: a hundred friends, each with a hundred friends, each with posts, each post re-resolving an author. One HTTP request became several million resolver invocations and a comparable pile of database reads. The server didn’t reject it — there was nothing configured to. This lesson is about the controls that should have been there: depth limiting, query-cost analysis, persisted queries, and then federation when the single graph itself becomes the bottleneck.
Why GraphQL needs hardening that REST does not
In REST, the server owns the shape of every response. GET /users/42/friends returns a bounded page; the cost of that endpoint is fixed and known when you write it. The client cannot ask /users/42/friends/friends/friends — there is no such route, and to get nested data the client must make more, separately rate-limitable, requests.
GraphQL inverts this. The client composes the query, choosing the fields, the depth, and the width in a single request. That expressiveness is the selling point, and it is also the attack surface: a client can ask for arbitrarily deep, arbitrarily wide, recursive data in one HTTP call, and the server will dutifully resolve it. A self-referential type like User.friends: [User] lets a query nest into itself until the response is a combinatorial explosion. One request, no auth required, becomes millions of resolver calls and DB hits. That is a denial-of-service vector unique to exposing a GraphQL endpoint, and it is why a public GraphQL API needs execution guards that a REST API gets for free from its fixed routes.
The defense is layered, and the layers run before execution — you reject the malicious query during validation, not by surviving it.
Control 1 — depth limiting
The first guard caps how deeply a query may nest. A validation rule walks the query AST and rejects any operation whose selection set is nested past a maximum — typically 7 to 10 levels for a real application. This is what stops the recursive-friends bomb: friends { friends { friends { ... } } } exceeds the depth limit and is rejected during validation, before a single resolver runs.
// main.ts — wire a depth-limit validation rule into Apollo's validationRules
import depthLimit from 'graphql-depth-limit';
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
// any operation nested deeper than 7 selection sets is rejected at validation
validationRules: [depthLimit(7)],
});Depth limiting is cheap, static, and catches the most obvious recursion. But it has a blind spot, and that blind spot is the next control.
Control 2 — query complexity / cost analysis
A query can be shallow and still ruinous. users(first: 10000) { posts(first: 1000) { comments(first: 1000) } } is only three levels deep — it sails past any depth limit — yet it multiplies to roughly ten billion nodes. Depth limiting counts nesting; it is blind to width and to pagination multipliers.
Cost analysis fixes this. You assign a numeric complexity to each field — scalar fields are cheap, list fields are expensive, and a list field’s cost is multiplied by its pagination argument — then sum the cost of the whole query before execution and reject anything over a budget (commonly 1000). NestJS supports this through a complexity plugin that computes a score against per-field estimators or a complexity value on the field.
// Annotate the expensive field, and multiply by the pagination arg
@Field(() => [Post], { complexity: 5 })
posts: Post[];
// A plugin sums the query's complexity at validation time and rejects over budget
import { ComplexityPlugin } from './complexity.plugin'; // computes score, throws over maxComplexity (e.g. 1000)The estimator for a paginated list multiplies the field’s base cost by first/limit, so users(first: 10000) scores 10000× the per-user cost and blows the budget on its own. This is the control that catches the wide-but-shallow bomb a depth limit waves through. You need both: depth for recursion, cost for fan-out.
▸Why this works
Why is a depth limit alone insufficient, and what does complexity analysis add? A shallow query can still be ruinously expensive. users(first: 10000) { posts(first: 1000) { comments(first: 1000) } } is only three levels deep, so a depth limit of 7 lets it straight through — yet the pagination arguments multiply to roughly ten billion nodes and melt the database. Depth limiting counts how deep you nest; it is completely blind to how wide each level is and to the multiplier effect of first/limit. Complexity analysis assigns a score that multiplies list sizes by their pagination arguments, so the same wide-but-shallow query scores far over budget and is rejected at validation. The two controls cover orthogonal axes — depth stops recursion, cost stops fan-out — and a hardened endpoint runs both, plus hard caps on pagination arguments themselves, request timeouts, and rate limiting.
Control 3 — persisted queries (the strongest lever)
The previous two controls bound an arbitrary query. Persisted queries change the game by removing arbitrariness. Instead of accepting any query string, the client sends a hash of a query that was registered ahead of time; the server looks up the hash and runs only the operation it already knows. Automatic Persisted Queries (APQ) do this primarily to shrink request payloads — the client sends a short hash instead of a long query string.
The security win comes from running persisted queries in allow-list mode: the server executes only operations from a vetted, pre-registered set, and rejects any hash it has not seen. In that mode arbitrary-query DoS is eliminated outright — an attacker cannot send the recursive-friends bomb because the server will not run a query it was not given at build time. The tradeoff is flexibility: a public or third-party API whose clients legitimately compose novel queries cannot allow-list them. For a first-party app — your own web and mobile clients shipping a fixed, known set of operations — allow-listed persisted queries are the single strongest hardening lever, and they compose with depth and cost limits as defense in depth.
The runtime cousin: N+1 and DataLoader
Cost analysis bounds what the client can ask for; it says nothing about what your resolvers actually do while answering. A resolver fan-out re-introduces the N+1 problem — resolving a list of N parents and then firing one child query per parent — which (as covered earlier in this unit) DataLoader solves by batching the N reads into one and caching per request. Think of them as a pair: cost limiting is the static budget on the request shape, DataLoader is the runtime budget on resolver work. A hardened API needs both, because a query that passes the cost gate can still issue N+1 queries if its resolvers are naive.
Federation — when the single graph is the bottleneck
The controls above protect one graph. Have you ever had to wait for another team’s schema review before shipping your own type? That’s the ownership mess federation is built to dissolve. As the graph grows, a single monolithic schema and resolver set becomes a deploy bottleneck — every team editing one file, every change a full redeploy. Apollo Federation (in NestJS, the ApolloFederationDriver) splits the graph across services. Each subgraph owns its own types, marks the entities it owns with @key(fields: "id"), and provides a __resolveReference resolver so other subgraphs can extend its entities by reference. A gateway composes the subgraphs into one supergraph and plans cross-service queries, fetching each entity’s fields from the subgraph that owns them.
# users subgraph — owns the User entity, identified by id
type User @key(fields: "id") {
id: ID!
name: String!
}# reviews subgraph — extends User by reference, adds the fields it owns
type User @key(fields: "id") {
id: ID! @external
reviews: [Review!]! # resolved here via __resolveReference(id)
}This is the modern, declarative successor to older schema stitching, where a gateway manually merged schemas and you wrote the cross-service wiring by hand. Federation makes the relationships declarative — @key and __resolveReference — and lets the gateway plan the query. Crucially, hardening does not move to the gateway and stop there: depth limits, cost analysis, and persisted queries still belong at the edge, because the supergraph is exactly as composable — and therefore exactly as abusable — as the single graph it replaced.
You must protect a public GraphQL API from a query-cost DoS, where a single client-composed query can fan out to millions of resolver and DB calls. What is the best layered defense?
Why can a single GraphQL request be a denial-of-service vector when a single REST endpoint generally is not?
A query only three levels deep still melts the database. Why does a depth limit miss it, and which control catches it?
- 01Explain why a public GraphQL endpoint is a DoS vector REST is not, and the three controls that harden it — being precise about what each one catches and misses.
- 02Explain Apollo Federation: the problem it solves, how subgraphs and the gateway compose a graph, the role of @key and __resolveReference, how it differs from schema stitching, and where hardening lives.
A public GraphQL endpoint is a denial-of-service vector that REST is not, because the client composes the query and can demand arbitrarily deep, wide, recursive data in one request — a self-referential type lets user { friends { friends { ... } } } fan out to millions of resolver and DB calls with no auth. The defense is layered and runs before execution. Depth limiting caps nesting at ~7-10 and rejects the recursive bomb, but is blind to width and pagination multipliers. Query complexity/cost analysis assigns a cost per field, multiplies list fields by their pagination argument, sums the query, and rejects over a budget (~1000) — catching the wide-but-shallow query like users(first: 10000) that is only three levels deep yet multiplies to billions of nodes; you need both controls because they cover orthogonal axes. Persisted queries in allow-list mode send a hash of a pre-registered query and run only vetted operations, eliminating arbitrary-query DoS outright — the strongest lever for first-party clients, too rigid for public ones. Pagination caps, timeouts, and rate limiting are outer layers, and DataLoader bounds the runtime N+1 that cost analysis doesn’t address. When the single graph itself becomes a deploy and ownership bottleneck, Apollo Federation splits it across subgraphs that own entities via @key and resolve references via __resolveReference, while a gateway composes them into one supergraph — the modern successor to manual schema stitching. The hardening gate stays at the edge, because the supergraph is exactly as abusable as the single graph it replaced. Now when you open a GraphQL API for partners or the public, you’ll know exactly which controls to wire before the first external query arrives — and which one call will shut down your database if you don’t.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.