Clients compose their own queries, so a single request can ask for a million rows or recurse through a relationship until the server runs out of memory. After this lesson you will be able to name the attacks that are specific to GraphQL, limit query depth and cost, and configure a production server so it exposes no more than it must.
Standard web security still applies; on top of it, the query language opens its own attack surfaces:
| Threat | Example | Defence |
|---|---|---|
| Deep nesting | user { friends { friends { friends ... } } } on a cyclic schema | Depth limit |
| Expensive selections | posts(first: 1000) { comments(first: 1000) { ... } } | Complexity or cost limit |
| Alias and batch abuse | 500 aliased login attempts in one request | Alias limit, per-field rate limit |
| Schema disclosure | Introspection reveals internal fields and error suggestions | Disable in production |
| CSRF via simple requests | GET or text/plain mutations from another origin | Preflight enforcement |
Any schema with a cycle (User.posts and Post.author) can be nested indefinitely. The graphql-depth-limit package adds a validation rule that rejects documents above a chosen depth before execution starts:
import depthLimit from "graphql-depth-limit";
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(8)],
});Pick the limit from real client operations plus a margin; most applications never exceed 8 to 10. The rule runs during validation, so a rejected query costs almost nothing.
Depth alone misses wide queries. graphql-query-complexity assigns a cost to every field, multiplies lists by their first argument, and sums the total. With Apollo Server it runs in a plugin hook once variables are known:
import { getComplexity, simpleEstimator, fieldExtensionsEstimator } from "graphql-query-complexity";
const complexityPlugin = (schema, max = 1000) => ({
async requestDidStart() {
return {
async didResolveOperation({ request, document }) {
const complexity = getComplexity({
schema, query: document, variables: request.variables,
estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
});
if (complexity > max) {
throw new GraphQLError(`Query too complex: ${complexity} > ${max}`, { extensions: { code: "QUERY_TOO_COMPLEX" } });
}
},
};
},
});fieldExtensionsEstimator reads per-field costs declared in the schema, such as complexity: ({ args, childComplexity }) => args.first * childComplexity on a list field; simpleEstimator supplies the default of 1. Log complexity for a while before enforcing a limit.
GraphQL Armor bundles depth, cost, alias, directive and token limits plus field-suggestion blocking into one plugin for Apollo Server and GraphQL Yoga:
import { ApolloArmor } from "@escape.tech/graphql-armor";
const armor = new ApolloArmor({ maxDepth: { n: 8 }, maxAliases: { n: 15 }, costLimit: { maxCost: 1000 } });
const server = new ApolloServer({ schema, ...armor.protect() });It is the quickest way to cover the whole table above.
NODE_ENV is production; set introspection: false explicitly to be sure, and let tooling read the schema from the repository.passwordHash?" leaks names even with introspection off; Armor's blockFieldSuggestions removes them.csrfPrevention on and only accept application/json bodies so browsers must preflight cross-origin requests.login per user or IP rather than per request.Why is a depth limit not sufficient on its own?
graphql-depth-limit rejects deep documents during validation at almost no cost.graphql-query-complexity scores queries by field cost and list size; enforce a limit after observing real traffic.Next lesson: Performance: Persisted Queries, APQ and Response Caching — reduce payload sizes, allow-list operations and cache responses safely.