GraphQL Security
Rules (for AI agents)
ALWAYS
- Enforce a cost budget on every operation: a maximum depth, plus a complexity
score that weights list fields by the page size they can request. Derive the numbers
from your own schema and measure them — a depth of 7 is generous for a flat schema
and far too permissive for one with a many-to-many edge, where five levels of
nesting can address billions of nodes.
references/graphql-limits.md covers how to
derive them and where each server configures them.
- Rate-limit by cost consumed, not by request count. Every operation arrives at
the same URL, so the per-route limit
api-security specifies cannot tell a trivial
query from one costing a thousand times more. Charge the complexity score against
the caller's budget.
- For a public or high-traffic API, restrict it to pre-registered operations — a
build-time allowlist of the documents the client bundle actually contains, keyed by
hash. Automatic Persisted Queries are not this: APQ lets a client register any
operation it likes and then send its hash, which is a bandwidth optimization.
Turning on APQ and believing the API is now allowlisted is a common and expensive
mistake.
- Cap aliases per operation and operations per batch, and apply that counting to
authentication and other rate-limited paths. One request carrying a hundred
aliased
login fields is a hundred attempts against any counter that increments
once per request — this is the mechanism behind the account-takeover-by-batching
incidents, not a bandwidth concern.
- Batch resolver fetches with a DataLoader or a join. An N+1 resolver turns one
cheap-looking query into a database round trip per parent row — the same
amplification the cost limit exists to bound, except it happens below the cost
analysis where that limit cannot see it.
- Key any response cache on the operation hash, the variables, and the caller's
authorization claims.
POST /graphql is one URL for every request, so a cache keyed
on the URL serves one tenant's data to another.
- Validate the contents of
JSON, custom scalar, and loosely-typed input arguments
inside the resolver. The schema's type checking is what makes GraphQL inputs feel
safe, and a JSON scalar opts straight out of it: whatever arrives is handed
through unexamined.
- Disable introspection and the in-browser playground (GraphiQL, Apollo Sandbox) on
production endpoints — and understand what that buys. It raises the cost of
reconnaissance and nothing more: field suggestions in error messages still leak
names, tools reconstruct schemas from them, and every field stays callable whether
or not it can be listed. Schema obscurity is never the authorization.
- Consult
auth-security for authorization, and run it per field rather than at
the endpoint. One HTTP response aggregates many resolvers, so a single unguarded
sensitive field leaks through any query that reaches it.
- Consult
error-handling-security for what an error may carry. The errors array is
GraphQL's public contract and field-level validation detail belongs in it; what must
not appear there is the stack trace, the SQL, or resolver internals. Flattening a
validation failure to a bare INTERNAL_SERVER_ERROR is hostile to callers without
being safer.
- Consult
file-upload-security for GraphQL multipart uploads. Arriving over GraphQL
changes nothing about the rules there — quarantine, type validation, expansion
limits, and scanning policy all still apply.
NEVER
- Trust a query's shape because "our clients only send well-formed queries". Anyone
can post to
/graphql.
- Let
@skip / @include decide whether an authorization check runs. They are
execution directives evaluated before resolvers, which makes them a probe
channel — an attacker toggles a field and reads the difference in the response —
not a place to put a security decision.
- Rely on a hand-rolled executor to reject a cyclic fragment. The specification
requires the
NoFragmentCycles validation rule and conforming servers enforce it
during validation; an executor that skips the validation phase does not.
KNOWN FALSE POSITIVES
- Pre-registered operations are already bounded, so depth and complexity checks on
them are redundant. Keep the checks for anything arriving outside the allowlist.
- A public, read-only data API may run deliberately high cost limits with aggressive
CDN caching. That is a documented per-endpoint trade-off, not an oversight.
- Introspection left on for an internal developer tool is acceptable only where the
endpoint's authorization would be acceptable with introspection both on and off.
Network placement — a VPN, a private subnet — is not the justification;
reachability is not authentication.
Context (for humans)
GraphQL is not more powerful than a REST API in the computational sense. The query
language has no loops, no recursion — the specification's validation rules forbid
fragment cycles — and no arbitrary branching. What it has is amplification: one
request can address an enormous amount of work, and one URL carries all of it.
That combination is what defeats controls built for REST. A per-route rate limit sees
one route. A URL-keyed cache sees one key. A request counter sees one request, even
when it carries a hundred aliased login attempts. Each of those controls is doing
exactly what it was designed to do, against a shape it was not designed for — which
is why the GraphQL-specific answer is almost always to move the unit of accounting
from the request to the cost of the operation.
The second recurring failure is a control that sounds stronger than it is.
Introspection off is reconnaissance cost, not access control. Automatic Persisted
Queries are a bandwidth feature, not an allowlist. A schema type is a parser
guarantee, not validation, once a JSON scalar is in play.
References
1---2name: graphql-security3description: Bound and control a GraphQL endpoint: operation cost budgets, cost-based rate limiting, pre-registered operations, alias and batch abuse on authentication paths, introspection, cache keying, and untyped scalar inputs. Use when generating schemas, resolvers, or server config, wiring limits or persisted operations, or reviewing a public /graphql endpoint.4---56# GraphQL Security78## Rules (for AI agents)910### ALWAYS11- Enforce a **cost budget** on every operation: a maximum depth, plus a complexity12 score that weights list fields by the page size they can request. Derive the numbers13 from your own schema and measure them — a depth of 7 is generous for a flat schema14 and far too permissive for one with a many-to-many edge, where five levels of15 nesting can address billions of nodes. `references/graphql-limits.md` covers how to16 derive them and where each server configures them.17- Rate-limit by **cost consumed**, not by request count. Every operation arrives at18 the same URL, so the per-route limit `api-security` specifies cannot tell a trivial19 query from one costing a thousand times more. Charge the complexity score against20 the caller's budget.21- For a public or high-traffic API, restrict it to **pre-registered operations** — a22 build-time allowlist of the documents the client bundle actually contains, keyed by23 hash. Automatic Persisted Queries are **not** this: APQ lets a client register any24 operation it likes and then send its hash, which is a bandwidth optimization.25 Turning on APQ and believing the API is now allowlisted is a common and expensive26 mistake.27- Cap aliases per operation and operations per batch, and apply that counting to28 **authentication and other rate-limited paths**. One request carrying a hundred29 aliased `login` fields is a hundred attempts against any counter that increments30 once per request — this is the mechanism behind the account-takeover-by-batching31 incidents, not a bandwidth concern.32- Batch resolver fetches with a DataLoader or a join. An N+1 resolver turns one33 cheap-looking query into a database round trip per parent row — the same34 amplification the cost limit exists to bound, except it happens below the cost35 analysis where that limit cannot see it.36- Key any response cache on the operation hash, the variables, **and** the caller's37 authorization claims. `POST /graphql` is one URL for every request, so a cache keyed38 on the URL serves one tenant's data to another.39- Validate the contents of `JSON`, custom scalar, and loosely-typed `input` arguments40 inside the resolver. The schema's type checking is what makes GraphQL inputs feel41 safe, and a `JSON` scalar opts straight out of it: whatever arrives is handed42 through unexamined.43- Disable introspection and the in-browser playground (GraphiQL, Apollo Sandbox) on44 production endpoints — and understand what that buys. It raises the cost of45 reconnaissance and nothing more: field suggestions in error messages still leak46 names, tools reconstruct schemas from them, and every field stays callable whether47 or not it can be listed. Schema obscurity is never the authorization.48- Consult `auth-security` for authorization, and run it **per field** rather than at49 the endpoint. One HTTP response aggregates many resolvers, so a single unguarded50 sensitive field leaks through any query that reaches it.51- Consult `error-handling-security` for what an error may carry. The `errors` array is52 GraphQL's public contract and field-level validation detail belongs in it; what must53 not appear there is the stack trace, the SQL, or resolver internals. Flattening a54 validation failure to a bare `INTERNAL_SERVER_ERROR` is hostile to callers without55 being safer.56- Consult `file-upload-security` for GraphQL multipart uploads. Arriving over GraphQL57 changes nothing about the rules there — quarantine, type validation, expansion58 limits, and scanning policy all still apply.5960### NEVER61- Trust a query's shape because "our clients only send well-formed queries". Anyone62 can post to `/graphql`.63- Let `@skip` / `@include` decide whether an authorization check runs. They are64 execution directives evaluated before resolvers, which makes them a **probe65 channel** — an attacker toggles a field and reads the difference in the response —66 not a place to put a security decision.67- Rely on a hand-rolled executor to reject a cyclic fragment. The specification68 requires the `NoFragmentCycles` validation rule and conforming servers enforce it69 during validation; an executor that skips the validation phase does not.7071### KNOWN FALSE POSITIVES72- Pre-registered operations are already bounded, so depth and complexity checks on73 them are redundant. Keep the checks for anything arriving outside the allowlist.74- A public, read-only data API may run deliberately high cost limits with aggressive75 CDN caching. That is a documented per-endpoint trade-off, not an oversight.76- Introspection left on for an internal developer tool is acceptable only where the77 endpoint's authorization would be acceptable with introspection both on and off.78 Network placement — a VPN, a private subnet — is not the justification;79 reachability is not authentication.8081## Context (for humans)8283GraphQL is not more powerful than a REST API in the computational sense. The query84language has no loops, no recursion — the specification's validation rules forbid85fragment cycles — and no arbitrary branching. What it has is **amplification**: one86request can address an enormous amount of work, and one URL carries all of it.8788That combination is what defeats controls built for REST. A per-route rate limit sees89one route. A URL-keyed cache sees one key. A request counter sees one request, even90when it carries a hundred aliased login attempts. Each of those controls is doing91exactly what it was designed to do, against a shape it was not designed for — which92is why the GraphQL-specific answer is almost always to move the unit of accounting93from *the request* to *the cost of the operation*.9495The second recurring failure is a control that sounds stronger than it is.96Introspection off is reconnaissance cost, not access control. Automatic Persisted97Queries are a bandwidth feature, not an allowlist. A schema type is a parser98guarantee, not validation, once a `JSON` scalar is in play.99100## References101102- `references/verifying-findings.md` — confirm or refute a finding, then lock it103- `references/graphql-limits.md` — deriving depth and complexity from your schema,104 where each server configures limits, and APQ versus trusted documents105- `rules/graphql_safe_config.json`106- [OWASP GraphQL Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/GraphQL_Cheat_Sheet.html).107- [GraphQL specification — validation](https://spec.graphql.org/).108- [CWE-400](https://cwe.mitre.org/data/definitions/400.html).109- [graphql-armor](https://escape.tech/graphql-armor/).