Security and Demand Control
|
This section documents the current GraphQL specification (October 2021), plus the working draft for
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as GraphQL tooling iterates quickly. |
A GraphQL schema’s flexibility — clients compose arbitrary selections, nest relationships, and repeat fields
under aliases — is also its attack surface: a single, small request can ask a server to do an enormous amount
of work. This page catalogs the layered controls production servers use to bound that work and to stop a
schema from disclosing more than it should, and marks where each control plugs into the request lifecycle
introduced in Getting started.
Why demand control is a distinct concern
REST APIs get a version of this protection for free: an endpoint’s URL and query parameters bound the shape of
work a request can trigger, and a rate limiter keyed on endpoint + method already limits abuse reasonably well.
A GraphQL endpoint is a single URL that accepts an open-ended query document, so the equivalent protections
have to be reconstructed deliberately, against the query document itself rather than against a fixed set of
routes. None of the controls below are part of the core specification — they are conventions that production
servers and gateways layer on top of it, most commonly at the validation stage
(Validation — where operation limits plug
in already introduces depth and complexity limiting from the validation side; this page covers the full set of
controls, including the ones that act before validation and after execution):
The rest of this page walks each box in that diagram, roughly in the order a request meets them, plus the authentication/authorization boundary that sits alongside — but conceptually apart from — demand control.
Trusted documents, persisted operations, and allowlisting
The most restrictive control is to stop accepting arbitrary query text at all. A trusted documents (also called persisted operations or persisted queries) setup has the server accept only a request that references a query document it already knows about — typically by a content hash — rather than the full query string:
POST /graphql HTTP/1.1
Content-Type: application/json
{
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"
}
},
"variables": { "id": "42" }
}
The client and the build pipeline that ships it agree on the set of query documents in advance (usually extracted at build time), the server pre-registers or lazily caches each hash the first time it sees the full query text alongside it, and every request after that carries only the hash and its variables. An arbitrary, never-registered query is rejected outright — an attacker cannot submit a query the client-side build never emitted, no matter how it is crafted. A simpler variant, allowlisting, skips the hash negotiation and just maintains a static list of approved query documents the server will execute, refusing everything else with a plain error. Both approaches trade query flexibility (ad hoc queries from tools like GraphiQL stop working against a production endpoint) for the strongest guarantee on this page: unknown request shapes never reach validation or execution at all. See graphql.org/learn/security for where this sits among the other controls below.
Disabling introspection and field suggestions in production
Introspection — why production
servers restrict it covers this in depth: introspection’s schema/type queries hand an unauthenticated
caller a complete map of every type, field, and argument the API exposes, and the "did you mean x?" field
suggestion many servers emit lets an attacker enumerate field names one guess at a time even with introspection
itself disabled. Both are usually turned off outside development as a first, cheap hardening step — it costs
nothing to a legitimate client that already knows the schema from its own generated types or documentation, and
it removes the easiest reconnaissance path for everyone else. It is a reconnaissance control, though, not a
demand-control one on its own: disabling introspection does nothing to stop a client that already knows the
schema from sending a deep, expensive, or high-volume query, which is why the rest of this page exists.
Enforcing pagination on list fields
Any field that returns a list is a potential unbounded-response vector unless the schema itself caps how much
it can return in one call. Pagination covers connection-style pagination
in depth; from a demand-control angle, the requirement is narrower: every list field must accept a page-size
argument, and the server must enforce a maximum on it server-side rather than trusting the client’s own limit:
type Query {
# Unbounded: a client can pass first: 1000000 and force the server to
# materialize the entire table in one response.
reviewsUnsafe(first: Int): [Review!]!
# Safe: the resolver clamps `first` server-side to a configured maximum
# (for example 100) regardless of what the client requests.
reviews(first: Int = 20): ReviewConnection!
}
A page-size argument with no server-side ceiling is not a mitigation at all — it only changes the shape of the request an attacker sends, not the amount of work the server will do in response to it.
Limiting depth, breadth, aliases, and batching
A recursive or self-referential type (a Comment with replies: [Comment!]!, a Person with friends:
[Person!]!) lets a client nest a selection set arbitrarily deep, and GraphQL’s alias syntax lets the same
field be requested many times under different names in a single operation — both turn one small request into
exponential or linearly multiplied server-side work:
query Attack {
book(id: "1") {
author {
books {
author {
books {
author { name } # nesting continues well past any legitimate UI need
}
}
}
}
}
a1: expensiveReport(year: 2020) { total }
a2: expensiveReport(year: 2021) { total }
a3: expensiveReport(year: 2022) { total }
a4: expensiveReport(year: 2023) { total }
# ...repeated dozens more times, each alias triggering its own resolver call
}
Depth limiting rejects a query whose selection-set nesting exceeds a configured maximum, counted along the
query document’s syntax tree regardless of which types the nesting passes through. Breadth, alias, and batch
limiting is the complementary check: it caps how many times a single field (or the operation as a whole) may
appear, whether through repeated aliases as shown above or through a document that defines many independent
operations in one request. Both run as custom rules at the validation stage, alongside the specification’s own
rule set covered in Validation — a query over the configured depth or
alias count never reaches a resolver, and the server responds with the same errors-only, no-data shape any
other validation failure produces. See graphql.org/learn/security for the
narrative case for both limits.
Scoring query complexity and cost
Depth and alias counts are cheap proxies for cost, but they don’t account for the fact that some fields are far
more expensive than others — a field backed by a single indexed lookup and a field that triggers a full-table
aggregation can sit at the same depth and alias count while costing wildly different amounts of server work.
Complexity (or cost) analysis fixes that by assigning a numeric weight to each field — commonly declared
alongside the field itself with a custom schema directive — and rejecting any query whose total, summed across
every selected field (and multiplied by any first/limit argument on list fields, since selecting a field
under a list of 100 costs roughly 100 times what selecting it once does), exceeds a configured budget:
type Query {
# Cheap: a single row lookup by primary key.
book(id: ID!): Book @cost(weight: 1)
# Expensive: an aggregation across the whole reviews table, multiplied
# by the list size a client requests.
topReviewedBooks(first: Int!): [Book!]! @cost(weight: 20, multipliers: ["first"])
}
@cost is not part of the specification — it is a convention several server libraries implement under slightly
different names and argument shapes, so the exact directive syntax and default weights are always
library-specific. Like depth and alias limiting, complexity analysis runs during validation, walking the same
parsed, type-annotated query document before any resolver executes, so an over-budget query is rejected with no
partial work done at all.
|
This page is generated with the assistance of AI. The |
Execution timeouts
Depth, alias, and complexity limits catch queries that are structurally or numerically expensive, but they cannot catch every slow path — a resolver that calls a slow downstream service, or a database query whose cost depends on data distribution rather than query shape, can still run long even under a query that looked cheap on paper. An execution timeout is the backstop: the server aborts an in-flight request once it exceeds a configured wall-clock budget, returning a timeout error rather than letting one slow request hold a worker thread or connection indefinitely. Because GraphQL resolves a tree of resolvers concurrently where possible, a well-implemented timeout needs to cancel the whole in-flight tree — including any downstream calls a resolver already started — not just stop waiting on the top-level response, or the server keeps doing the wasted work even after it has stopped waiting for the result.
Rate limiting
A single request can be perfectly within every limit above and still be sent thousands of times a second. Rate
limiting a GraphQL endpoint has to account for the fact that every request hits the same URL and HTTP method, so
a naive "requests per second per route" limiter treats a request for a single field the same as one that
computes the complexity-budget maximum. Two approaches are common in practice: limiting by raw request count
(simplest, but blind to per-request cost) and limiting by the summed complexity score consumed over a rolling
window (reuses the same cost function from the previous section, so an expensive query counts more against the
budget than a cheap one). Either way, a throttled request gets a 429 Too Many Requests response rather than a
GraphQL-shaped errors array, since the request is being rejected at the transport layer before GraphQL even
parses it.
Redacting errors in production
Response and error handling covers the errors array’s
full shape; the demand-control concern is narrower: an unhandled exception’s message and stack trace are
implementation detail that can leak table names, internal service hostnames, or library versions to a client
that was never meant to see them. A production server should catch any resolver error that wasn’t deliberately
raised as a client-facing error, replace its message with a generic one ("Internal server error"), and drop
the original detail from the response entirely — keeping it only in server-side logs, correlated by a request
ID the client is allowed to see:
{
"errors": [
{
"message": "Internal server error",
"path": ["book", "reviews"],
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"requestId": "3f9a2b7e-6c1d-4e2a-9d31-8f0b1c2d3e4f"
}
}
]
}
This is purely a presentation concern layered on top of the envelope Getting
started already introduced as \{ data, errors } — redaction changes what goes into an error’s message and
extensions, not the response’s overall shape or its partial-success semantics.
The authentication-versus-authorization boundary
Everything above bounds the amount and shape of work a request can trigger, regardless of who sent it.
Authentication — establishing who the caller is — is a separate concern that has to happen before GraphQL
execution begins: a server validates the caller’s credentials (a session cookie, a bearer token) at the
transport layer and places the resulting identity into the resolver context object, the same context every
resolver receives on every call. Authorization — deciding what that identity is allowed to do — is a
distinct step again, and one this page deliberately does not cover: Authorization
goes deep on where authorization checks belong (the domain/service layer versus per-field policies), per-type
versus per-field enforcement, and why relying on demand control alone as an access-control mechanism is a
mistake — a perfectly depth-limited, rate-limited query can still ask for data the caller has no right to see.
Summary of demand-control layers
| Control | What it defends against | Lifecycle stage |
|---|---|---|
Trusted documents / allowlisting |
Arbitrary, unvetted query documents. |
Before parse |
Introspection / suggestion lockdown |
Schema reconnaissance. |
Transport / server config |
Pagination ceilings |
Unbounded list responses. |
Execute (resolver) |
Depth limiting |
Recursive/self-referential nesting attacks. |
Validate |
Breadth / alias / batch limiting |
Repeated-field amplification via aliases or multi-operation documents. |
Validate |
Complexity / cost analysis |
Field-weighted expensive queries that look small. |
Validate |
Execution timeouts |
Slow downstream calls not caught by static limits. |
Execute |
Rate limiting |
High-volume repetition of otherwise-valid requests. |
Transport, before parse |
Error redaction |
Leaking implementation detail through error messages. |
Respond |
Further reading
-
graphql.org/learn/security — the narrative introduction to every control on this page.
-
Section 5 of the October 2021 specification — the validation chapter that depth, complexity, and alias limits attach to as custom rules.
-
Validation— the specification’s own rule families, and exactly where custom operation limits plug into the same phase. -
Introspection— the meta-fields this page’s introspection-lockdown section assumes as background. -
Pagination— connection-style pagination in depth, referenced above for enforcing list-field ceilings. -
Tips: When to Use GraphQL or a Typical REST API— why these controls bound query shape but not query-plan quality, and when to choose GraphQL over REST. -
Response and error handling— the fullerrorsobject shape that redaction in production narrows down. -
Authorization— what happens after a request clears every control on this page: deciding what an authenticated caller is actually allowed to see or change. -
Performance and N+1— batching and caching techniques that reduce legitimate cost, complementary to the limits on this page that reject illegitimate cost outright.