Depth, complexity and hostile queries
A public GraphQL endpoint lets a client write its own denial of service.
Open this lesson in the learning hubKey points
- The flexibility that makes GraphQL good for clients also lets one craft a query that costs enormously more to serve than it costs to send.
- A schema with a cycle - user has orders, order has a user - allows arbitrarily deep nesting, and each level multiplies the work.
- Depth limiting is the crude, essential first control: reject anything nested beyond a sensible bound, usually around 10.
- Complexity analysis is better: assign a cost to each field, multiply by requested list sizes, and reject above a budget. It catches the wide-and-shallow query depth limiting misses.
- Always bound list sizes. A
firstargument with no maximum is a request to return the whole table, and a default is not a limit. - For a known set of clients, persisted queries end the problem: the server accepts only queries it has been given in advance, by hash. Arbitrary queries are then simply not possible.
Example
# THE ATTACK - a small query, an enormous response.
query Bomb {
user(id: "1") {
orders(first: 100) {
edges { node { customer {
orders(first: 100) {
edges { node { customer {
orders(first: 100) { edges { node { id } } }
} } }
}
} } }
}
}
}
# ~200 bytes to send. Up to 1,000,000 nodes to resolve.
---
# Spring for GraphQL - the two limits worth having from day one.
@Bean
GraphQlSourceBuilderCustomizer limits() {
return builder -> builder.configureGraphQl(graphQl -> graphQl
.instrumentation(List.of(
new MaxQueryDepthInstrumentation(10),
new MaxQueryComplexityInstrumentation(1000))));
}
# Bound list sizes in the SCHEMA, and enforce them in the resolver:
@QueryMapping
public OrderConnection orders(@Argument Integer first) {
int size = Math.min(first == null ? 20 : first, 100); // HARD cap
return service.page(size);
}
---
# PERSISTED QUERIES - the strongest control for a known client set.
#
# build time: client extracts its queries and registers them
# sha256("query GetUser...") -> "a1b2c3..."
#
# runtime: the client sends only the HASH
# POST /graphql { "extensions": { "persistedQuery": {
# "version": 1, "sha256Hash": "a1b2c3..." } } }
#
# The server refuses anything it does not recognise, so an attacker
# cannot express a novel query at all. Smaller requests too.
---
# Also worth doing, in order of value:
# - introspection OFF in production (do not hand over the map)
# - per-operation timeout, so one query cannot run for minutes
# - rate limit by COMPLEXITY consumed, not by request count
# - log operationName + complexity, so you can see what is expensive
# - break schema cycles where the reverse edge is not actually needed
A 200-byte query can request a million nodes - cap depth and complexity, bound every list, and prefer persisted queries.
This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the GraphQL Course course, and every lesson in it is listed on the GraphQL Course contents page.