GraphQL Security and Rate Limiting
Query depth and complexity attacks with real examples, and rate limiting by query cost instead of request count.
The problem: one query, unbounded work
A REST endpoint does a roughly fixed, known amount of work per call — GET /posts/123 always does about the same thing. GraphQL's defining feature is letting the client shape an arbitrary query against the schema, and that same flexibility is exactly what makes an under-defended GraphQL API a bigger attack surface than a fixed set of REST endpoints: a single request can be constructed to trigger a wildly disproportionate amount of backend work, with no limit unless the server explicitly imposes one.
A real attack: deeply nested queries
Given the schema from earlier in this track, where a Post has an author and an Author has posts, nothing in the schema itself stops a client from nesting that relationship recursively:
query MaliciousDeepQuery {
post(id: "1") {
author {
posts {
author {
posts {
author {
posts {
title
}
}
}
}
}
}
}
}
Each additional level of nesting multiplies the number of resolver calls by however many posts or authors exist at that level — a handful of authors with a few dozen posts each turns into an exponentially growing resolver tree only a few levels deep, executed as a single client request with a single round-trip.
A second variant doesn't even need nesting — it exploits aliases, which let a client request the same field multiple times under different names in one request:
query AliasExplosion {
a1: expensiveReport { total }
a2: expensiveReport { total }
a3: expensiveReport { total }
# ...repeated hundreds of times, all inside ONE HTTP request
}
Both attacks rely on the same underlying gap: by default, the server has no limit on how deep or how wide a single query can be, and will dutifully attempt to resolve every field it's asked for.
The fix: depth limiting
The most basic defense counts a query's nesting depth before executing it, and rejects anything beyond a reasonable threshold:
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
schema,
validationRules: [depthLimit(6)],
});
This stops the deeply nested attack outright — the query above, at 6+ levels of author/posts nesting, would be rejected during validation, before a single resolver runs. Depth limiting alone, however, does nothing about the alias-explosion example: that query is only one level deep, just very wide.
The fix: cost/complexity analysis
A more complete defense assigns each field a cost, often scaled by its arguments, and rejects any query whose total calculated cost exceeds a budget — catching both the deep and the wide attack:
type Query {
posts(limit: Int = 10): [Post!]! @cost(complexity: 1, multipliers: ["limit"])
}
With a directive like this, posts(limit: 10) costs roughly 10 points, and posts(limit: 10000) costs roughly 10,000 — a query analysis pass sums the total cost across every field before execution starts and rejects it if that total exceeds whatever budget the server has configured, regardless of whether the cost came from deep nesting, wide aliasing, or an inflated pagination argument.
Rate limiting by cost, not request count
Standard REST-style rate limiting — "100 requests per minute per API key" — assumes each request costs roughly the same, which is exactly the assumption GraphQL breaks. A query as cheap as { __typename } and a query that fans out into a huge aggregation both count as "one request" under that model, which either lets a handful of enormous queries do outsized damage, or unfairly throttles a client making many genuinely cheap ones.
The fix is to rate limit by the calculated cost of each incoming query against a rolling budget, not by the count of HTTP requests:
async function checkRateLimit(clientId, queryCost) {
const bucket = await getBucket(clientId); // { tokens, lastRefillAt }
const now = Date.now();
const elapsedSeconds = (now - bucket.lastRefillAt) / 1000;
bucket.tokens = Math.min(MAX_TOKENS, bucket.tokens + elapsedSeconds * REFILL_RATE);
bucket.lastRefillAt = now;
if (bucket.tokens < queryCost) {
throw new Error('Rate limit exceeded — query too expensive for remaining budget');
}
bucket.tokens -= queryCost;
await saveBucket(clientId, bucket);
}
This is the same token-bucket idea used for ordinary request-count rate limiting, just deducting a query's calculated complexity score from the bucket instead of a flat 1 per request — a client's budget drains faster when it sends expensive queries and slower when it sends cheap ones, which is the behavior that actually matches the real cost being placed on the server.
Other essential defenses
- Disabling introspection in production is debated — it breaks some legitimate developer tooling, but a public introspection endpoint does hand an attacker a complete, self-documenting map of every field, argument, and relationship to target.
- Persisted queries / allow-listing restrict a production API to a known, pre-approved set of queries (covered in depth on the caching-strategies page), which is a strong defense in its own right: an attacker can't construct an arbitrary malicious query at all if the server only executes queries it already knows about.
- A hard execution timeout per query, applied regardless of the defenses above, is a useful backstop against anything that slips through cost analysis but still turns out to be unexpectedly slow in practice.
Comparison table
| Defense | Stops | Doesn't stop |
|---|---|---|
| Depth limiting | Deeply nested, recursive queries | A shallow query that's wide (many aliases) or has an inflated pagination argument |
| Cost/complexity limiting | Both deep and wide expensive queries | A well-crafted query that stays just under budget but is still run very frequently |
| Per-request rate limiting | Raw request flooding | One legitimately "1 request" query that's enormously more expensive than a typical one |
| Cost-based rate limiting | Sustained abuse from expensive queries run repeatedly, even within a per-query budget | Anything outside the query layer itself (e.g., a compromised, legitimately-authenticated client) |
Common mistakes
- Rate limiting a GraphQL API purely by request count, the way a REST API commonly is, letting a handful of enormous queries do more damage than thousands of cheap ones ever could.
- Adding depth limiting alone and assuming it covers query cost — a shallow query requesting a huge
limitor many aliased expensive fields sails right through a depth check untouched. - Setting cost limits so loose (to avoid breaking legitimate large queries) that they don't meaningfully constrain anything, or so tight that ordinary nested queries the real UI already uses get rejected — this needs to be calibrated against actual usage, not guessed at.
- Forgetting that public introspection combined with no other protections hands an attacker a complete, self-documenting map of exactly which fields and relationships to target.