GraphQL Attacks: Introspection, Nested Queries & Batching
How GraphQL gets attacked: exposed introspection, deeply nested query denial of service, batching abuse, and defending with depth and cost limits.
GraphQL flips the usual API contract. Instead of the server deciding what each endpoint returns, the client asks for exactly the fields it wants in a single query. That is a genuine win for front-end teams, and it is also a new attack surface. The same query language that lets a client fetch precisely what it needs lets an attacker probe the schema, ask for something ruinously expensive, or pack many operations into one request.
This guide covers the GraphQL abuse patterns that come up most: introspection exposure, deeply nested query denial of service, and batching. Each has a clean defense rooted in limiting what a single query is allowed to do. For a broader foundation, see the GraphQL Security Guide.
Why GraphQL changes the threat model
A REST API exposes fixed endpoints, each returning a shape the server chose. A GraphQL API exposes a single endpoint and a typed schema, and the client composes the query. Three properties of that model matter for security.
First, the schema is a map of everything the API can do, and the client drives traversal through it. Second, one query can span many types and relationships, so its cost is not obvious from its size. Third, features built for developer convenience, like introspection and batching, are available to anyone who can reach the endpoint unless you turn them off. Attackers use all three.
Introspection exposure
Introspection is GraphQL's built-in self-documentation. A special query asks the server to describe its own schema: every type, every field, every argument, every mutation. Tools use it to generate documentation and autocomplete. An attacker uses it to skip reconnaissance entirely.
{ __schema { types { name fields { name } } } }
With introspection open, an attacker downloads the complete schema and immediately sees fields and mutations that the UI never calls: an admin-only mutation, an internal field, a deprecated endpoint still wired up. Even with introspection disabled, some servers leak schema information through field suggestions, where a mistyped field name returns a helpful "did you mean" that confirms real field names one guess at a time.
The defense is to disable introspection in production, disable the suggestion feature, and restrict any schema exploration to authenticated internal environments.
Turning off introspection slows reconnaissance; it does not protect data. A hidden field is still reachable by anyone who guesses or already knows its name. Every sensitive field and mutation still needs its own authorization check at the resolver. Treat introspection control as one layer, never the only one.
Deeply nested queries and denial of service
GraphQL schemas often contain cyclic relationships. A user has posts, a post has an author, an author has posts, and so on. Because the client controls the query, it can walk that cycle as deep as it likes.
{ user { posts { author { posts { author { posts { author { name } } } } } } } }
Each level multiplies the work the server must do to resolve the result. A query only a few kilobytes long can demand that the database and resolvers assemble a result set large enough to exhaust memory and CPU, taking the service down for everyone. This is a classic amplification: tiny input, enormous cost. On MITRE ATT&CK it maps to Endpoint Denial of Service (T1499).
The problem is that request size is a poor proxy for request cost in GraphQL. A short query can be catastrophically expensive, so defenses have to measure the query itself, not the bytes on the wire.
Batching and aliasing abuse
GraphQL lets a client send multiple operations in one HTTP request, and lets it alias the same field many times within a single query. Both features multiply the work done per request, and both defeat rate limits that only count requests.
Aliasing is the sharper tool. A single query can invoke the same expensive field or mutation hundreds of times under different alias names:
{ a1: login(user:"x", pass:"a") { ok } a2: login(user:"x", pass:"b") { ok } ... }
To a rate limiter that counts one HTTP request, this looks like a single attempt. To the server, it is hundreds of login attempts, which turns a login mutation into a credential-stuffing engine that walks straight past a per-request throttle. Batched arrays of operations do the same at the transport level.
The lesson is that in GraphQL the meaningful unit is the operation and its cost, not the HTTP request.
CSRF against GraphQL
Many GraphQL servers accept operations over both POST and GET, and some accept form-encoded bodies for convenience. That convenience opens the door to cross-site request forgery. If a mutation can be triggered by a GET request or a simple form submission, an attacker can host a page that fires the request from the victim's authenticated browser, and a state-changing operation runs without the user's intent.
The fixes are the standard anti-CSRF measures applied to the GraphQL endpoint. Accept mutations only over POST with a JSON content type, which browsers cannot forge cross-origin without a preflight, and require a CSRF token or rely on the SameSite cookie attribute for session cookies. Reject GET requests that attempt to run mutations. See CSRF Explained for the underlying pattern.
Injection through GraphQL arguments
GraphQL is a query language for your API, not for your database, so it does not remove the classic injection risks underneath. Every argument a client sends flows into a resolver, and that resolver often builds a database query, a system command, or another API call. If the resolver concatenates the argument into a query string, the endpoint is open to SQL or NoSQL injection just as a REST endpoint would be.
{ user(id: "1 OR 1=1") { name email } }
The GraphQL layer gives no protection here. Resolvers must use parameterized queries and validate argument types and ranges, the same discipline any backend needs. See NoSQL Injection Explained for how these payloads take shape against document databases.
Information disclosure through errors
GraphQL servers are often chatty when something goes wrong. Verbose error messages can leak stack traces, internal field names, database structure, and library versions, all of which help an attacker refine the next query. Combined with field suggestions, error output can reconstruct much of a schema even when introspection is disabled. In production, return generic error messages to clients and keep the detailed diagnostics in server-side logs only.
Why these flaws are so common
The recurring GraphQL vulnerabilities are not exotic. They come from features that ship enabled for developer convenience and from an assumption carried over from REST that does not hold. Introspection, field suggestions, batching, and verbose errors all make development faster, and all of them are reachable by anyone who can hit the endpoint unless a team deliberately turns them off for production. A schema left in its friendly development posture hands an attacker a map, a hint system, and a work multiplier without any special effort on their part.
The deeper cause is a mental model mismatch. REST trained a generation of developers to think of the endpoint as the unit of security: authorize the endpoint, rate limit the endpoint, and the shape the endpoint returns bounds the cost. GraphQL collapses many endpoints into one flexible query surface, so authorization has to move down to the field, rate limiting has to move to computed cost, and cost has to be measured rather than assumed from request size. Teams that lift REST habits directly onto a GraphQL server inherit gaps at exactly the points where the model changed. The good news is that the fixes are well understood and composable, which is why a short list of controls closes most of the surface.
A worked example: mapping and abusing a schema
Follow an attacker who reaches an unfamiliar GraphQL endpoint with no documentation. The steps below show how the model's flexibility compounds into real access when the defensive controls are missing.
The first move is reconnaissance, and GraphQL often hands it over for free. The attacker sends an introspection query and receives the full schema: every type, every field, every argument, every mutation. Where a REST API would force guessing at endpoint names, the introspection response names an administrative mutation, an internal field, and a deprecated operation still wired up, none of which the user interface ever calls. If introspection is disabled, the attacker falls back to field suggestions, sending mistyped field names and reading the helpful hints that confirm real names one guess at a time.
With the map in hand, the attacker looks for fields that lack their own authorization. Because the schema is flat and every type is reachable by anyone who can compose a query, a mutation that the front end only ever exposes to admins may run for any authenticated user if the resolver does not check permissions itself. The attacker composes a query that reaches that mutation directly, bypassing the interface that would have hidden it.
Next comes cost. The attacker notices a cyclic relationship in the schema, a user has posts, a post has an author, an author has posts, and writes a short query that walks the cycle many levels deep. A request only a few kilobytes long forces the server to assemble an enormous result set, straining memory and CPU. If there is no depth limit and no cost analysis, that single request degrades the service for everyone.
Finally, the attacker weaponises aliasing. A login mutation that a per-request rate limiter treats as one attempt can be invoked hundreds of times under different alias names inside a single query, turning it into a credential-stuffing engine that walks straight past the throttle. Each stage built on the last: introspection revealed the operation, missing authorization made it reachable, and aliasing multiplied it. The defenses later in this guide close each of those doors.
Detection signals: spotting GraphQL abuse
Because a GraphQL API exposes one endpoint, the useful signals live in the shape and content of the operations rather than in which URL was hit. Instrument the resolver layer and the gateway to surface these.
- Introspection queries in production traffic. Requests containing
__schemaor__typeagainst a public endpoint are almost always reconnaissance, since legitimate clients ship with the schema they need and do not ask the server to describe itself. - Repeated near-miss field names. A burst of queries with slightly wrong field names, each drawing a suggestion, is field-name enumeration in progress.
- Abnormal query depth. Operations that nest well beyond what any real screen requires, especially through cyclic relationships, point at a resource-exhaustion attempt.
- Heavy aliasing. A single operation that aliases the same field or mutation many times is a strong signal of rate-limit evasion or brute forcing, particularly against authentication mutations.
- Large batched operation arrays. A single HTTP request carrying a long array of operations multiplies work per request and is worth flagging and capping.
- High computed query cost. If you assign a cost to fields, queries whose computed cost sits far above the norm are the clearest single indicator of an expensive-query attack, whether or not they are deep.
The theme is that the meaningful unit in GraphQL is the operation and its computed cost, so detection and rate limiting both belong at that level rather than at the HTTP request.
Attack summary
| Attack | Abused feature | Impact | Primary defense |
|---|---|---|---|
| Introspection exposure | Schema self-documentation | Full schema disclosure | Disable introspection and suggestions in production |
| Field suggestion leakage | "Did you mean" hints | Field name enumeration | Turn off suggestions on production servers |
| Nested query DoS | Cyclic schema traversal | Resource exhaustion | Depth limit plus cost analysis |
| Batching and aliasing | Multiple ops per request | Rate-limit bypass, brute force | Cost-based limits, cap operations per request |
| Missing field authorization | Flat schema access | Data exposure | Resolver and field-level authorization |
How to defend a GraphQL API
The controls reinforce each other. Layer them.
- Disable introspection and suggestions in production. Keep them for internal, authenticated environments where developers need them, and turn them off on public endpoints.
- Enforce a maximum query depth. Reject any query that nests beyond a sensible limit for your schema, which kills the cyclic amplification.
- Add query cost analysis. Assign a cost to fields and resolvers, compute the total cost of an incoming query before executing it, and reject anything over a budget. This catches expensive queries that are not deep.
- Rate limit by cost, not by request. Charge each client's budget by the computed cost of their operations, and cap the number of operations and aliases allowed per request so batching cannot multiply work for free.
- Authorize at the resolver and field level. Do not assume the query shape reflects the user's permissions. Check authorization on every sensitive field and mutation, because the schema exposes them all.
- Use persisted or allowlisted queries. In production, accept only a known set of registered queries by identifier, so arbitrary attacker-crafted queries are never executed.
- Set timeouts and pagination limits. Cap execution time and force pagination so no resolver can return an unbounded list.
API and GraphQL vulnerabilities move fast once a working technique is public. Our Exploit Intelligence dashboard tracks live vulnerabilities across API and application components, so you can see which classes are under active pressure and patch accordingly.
GraphQL and REST: where the risk differs
The abuse patterns above are easier to see against the REST baseline that most teams already understand. The comparison shows why controls REST gave for free have to be rebuilt in GraphQL.
| Concern | REST | GraphQL |
|---|---|---|
| Endpoints | Many fixed endpoints, each a known shape | One endpoint, client composes the query |
| Cost of a request | Roughly bounded by the endpoint | Set by the query, can be huge from a tiny request |
| Reconnaissance | Guess endpoint names | Introspection or field suggestions map the schema |
| Rate limiting | Per request is a fair proxy for work | Per request is defeated by batching and aliasing |
| Authorization | Often per endpoint | Must be per field and per resolver on a flat schema |
| Injection risk | Present in every handler | Present in every resolver, GraphQL adds no protection |
The row that surprises teams most is cost. In REST the endpoint bounds the work, so request count tracks load. In GraphQL a short query can be catastrophically expensive, so request size and request count are both poor proxies for cost, and defenses have to measure the query itself. The injection row is the other trap: the query language sits in front of your API, not your database, so parameterised queries and input validation are still mandatory in every resolver.
Common misconceptions
"Disabling introspection secures the schema." Turning off introspection slows reconnaissance and hides nothing that is actually reachable. A field an attacker already knows or guesses is still callable, and field suggestions can rebuild much of the schema one hint at a time. Every sensitive field and mutation still needs its own authorization check. Treat introspection control as one layer among several.
"GraphQL protects against injection." GraphQL is a query language for your API, not for your database. Every argument flows into a resolver that often builds a database query, a system command, or another API call. If a resolver concatenates that argument into a query string, the endpoint is open to SQL or NoSQL injection exactly as a REST handler would be. Parameterised queries and type validation remain mandatory.
"Our rate limiter handles abuse." A limiter that counts HTTP requests is blind to batching and aliasing, both of which multiply the work inside a single request. Hundreds of aliased login attempts arrive as one request and pass the throttle untouched. Rate limiting has to charge by computed cost and cap operations and aliases per request to matter.
"Request size tells us if a query is dangerous." A few kilobytes of query can force the server to assemble a result set large enough to exhaust memory and CPU, because cyclic traversal multiplies work at every level. Size is a poor proxy for cost, which is why depth limits and cost analysis exist. Measure the query, not the bytes on the wire.
Frequently asked questions
Should I disable introspection in production? Yes, on public endpoints, and keep it available in internal, authenticated environments where developers need it. Disabling it slows an attacker's reconnaissance. It is one layer and not a substitute for authorization, since a determined attacker can still reach fields they know about and can often reconstruct the schema through field suggestions and verbose errors.
What is the difference between a depth limit and a cost limit? A depth limit rejects queries that nest beyond a set number of levels, which kills the cyclic amplification that deeply nested queries rely on. A cost limit assigns a weight to each field and resolver, computes the total for an incoming query before executing it, and rejects anything over a budget. Depth catches deep queries, and cost catches expensive queries that are shallow but wide, so you want both.
How does aliasing bypass rate limiting? Aliasing lets one query invoke the same field or mutation many times under different names. To a rate limiter that counts one HTTP request, a query with hundreds of aliased login attempts looks like a single attempt, while the server actually processes every one. Charging the client's budget by the computed cost of the operation, and capping the number of aliases and operations per request, closes the gap.
Are persisted queries worth the effort? For production APIs with a known set of client operations, yes. Persisted or allowlisted queries register each permitted operation by identifier, so the server accepts only known-good queries and never executes arbitrary attacker-crafted ones. That single control neutralises introspection abuse, nested-query denial of service, and aliasing at once, because an attacker-crafted query is never on the allowlist, at the cost of a registration step in your build pipeline.
Does GraphQL need CSRF protection? Yes, when it authenticates with cookies. Many servers accept operations over both POST and GET and some accept form-encoded bodies, which lets an attacker fire a state-changing mutation from a victim's authenticated browser. Accept mutations only over POST with a JSON content type, reject GET requests that run mutations, and require a CSRF token or rely on the SameSite cookie attribute.
Where should authorization live in a GraphQL server? At the resolver and field level, which is where GraphQL authorization has to live. The schema is flat, so every type is reachable by anyone who can compose a query, and the query shape does not reflect the user's permissions. Each sensitive field and mutation needs its own check, because disabling introspection or hiding a field from the interface does nothing to stop a crafted query from reaching it. A centralised authorization layer that every resolver consults, rather than ad hoc checks scattered through the code, keeps this consistent as the schema grows and new fields are added. Without it, one forgotten check on a newly added mutation quietly reopens the whole surface.
GraphQL hands power to the client by design, so the server has to reclaim the limits that REST gave it for free. Turn off the reconnaissance features you do not need, measure and cap the cost of every query, rate limit by work rather than by request, and authorize every field. Those controls leave the schema flexible for legitimate clients and closed to the abuse patterns above.
Related guides
Sources & further reading
Related guides
- CORS Explained: The Same-Origin Policy & How to Relax It
How the same-origin policy protects users, how CORS shares responses across origins, preflight and credentials, and how to configure it safely.
- CORS Misconfiguration: When Permissive Origins Leak Data
How CORS misconfigurations expose data: reflected origins with credentials, null and wildcard traps, and how to build a safe cross-origin policy.
- JWT Attacks: alg=none, Algorithm Confusion & Weak Secrets
How JSON Web Token attacks work: the alg=none trick, RS256-to-HS256 algorithm confusion, brute-forcing weak secrets, and how to defend.