The GraphQL Attack Surface: Introspection, Query DoS, Broken Authorization, and Injection

The GraphQL Attack Surface: Introspection, Query DoS, Broken Authorization, and Injection

Written by

in

The graphql attack surface comes from a single design choice that makes GraphQL pleasant to build against: the client, not the server, decides the shape of the response. One endpoint at /graphql accepts a typed query, and the caller asks for exactly the fields it wants, as deeply nested as it likes, in whatever batch it cares to assemble. That flexibility is the whole appeal, and it is also the whole problem. A REST API exposes a fixed menu of routes, each returning a fixed payload. A GraphQL API hands the caller a programmable interface to your data graph and trusts them to use it gently. This post walks the specific ways that trust gets abused: how introspection turns the schema into a map, how nested and batched queries turn one HTTP request into thousands of resolver calls, how authorization slips through the gaps between resolvers, how injection still reaches the database, and how to put guards back on each of those.

What makes the graphql attack surface different from REST

Start with the model, because every issue below falls out of it. A REST API is a set of endpoints. GET /notes/42 returns a note, POST /notes creates one, and each route is a separate, individually secured thing. The server owns the response shape. If GET /notes/42 does not include the author’s email, the client cannot ask for it; the field simply is not on that route.

GraphQL collapses all of that into one endpoint and one typed schema. Our invented app, Acme Notes, exposes everything through a single POST to /graphql. The client sends a query describing the exact shape it wants:

query {
  note(id: 42) {
    title
    author {
      name
      email
    }
  }
}

Three things follow from this design, and each one widens the attack surface. First, there is a typed schema that names every type, every field, and every operation, and GraphQL can describe that schema to anyone who asks. Second, the client chooses the shape and depth of the response, so the server cannot reason about one fixed payload; it has to answer whatever query arrives. Third, the work is done by resolvers, one small function per field, each fetching its piece. The query above runs a resolver for note, then for author, then for name and email. The server stitches the result together. That resolver model is elegant and it is exactly where authorization tends to leak, because each resolver is its own little decision point.

Introspection turns the schema into a map

GraphQL ships with a reflection system. A special set of meta fields, chiefly __schema and __type, lets a client ask the server to describe itself: every type, every field, every argument, every deprecated operation, and the relationships between them. This is what powers the autocomplete in a GraphQL IDE and the documentation explorer. It is genuinely useful for developers, and it is a reconnaissance goldmine for an attacker.

A single introspection query returns the full map. The canonical form walks __schema and pulls every type and field:

query {
  __schema {
    queryType { name }
    mutationType { name }
    types {
      name
      fields(includeDeprecated: true) {
        name
        args { name type { name } }
      }
    }
  }
}

Run that against an unguarded endpoint and you learn the entire data model in one request. You see mutations that are not linked anywhere in the UI. You see deprecated fields that still resolve. You see internal types like AdminUser or BillingAccount that the front end never touches but the resolver still serves. There is no guessing at route names the way you would brute force a REST API. The server tells you everything, accurately, because describing itself is a feature.

What makes this worse than a leaked REST documentation page is precision. Introspection is not a hint or a sample; it is the authoritative description the server uses to validate every query. The argument types it reports are the exact types it enforces. The deprecated fields it lists still resolve, because deprecation in GraphQL is a label, not a removal. An attacker who pulls the schema knows, before sending a single real query, which mutation creates an admin, which field exposes a token, and which argument is an unbounded string. Mapping a REST API is archaeology; mapping a GraphQL API is reading the blueprint the builder left on the table.

Disabling introspection in production helps but does not fully close the door. Many GraphQL servers, Apollo among them, return field suggestions in error messages: ask for a field that does not exist and the server helpfully replies did you mean, leaking real field names one guess at a time. The tool clairvoyance, by Nikita Stupin, automates exactly this, recovering all or part of a schema from those suggestions even when __schema is turned off. On the testing side, InQL from Doyensec is a Burp Suite extension that parses introspection into ready to send query templates and detects circular references, and graphql-cop by Dolev Farhi is a small auditor that checks whether introspection, suggestions, batching, and depth limits are left open. These are accurate, real tools, and they make the reconnaissance step nearly free. The takeaway is that introspection is a default on convenience, and leaving it on in production means publishing your data model to anyone who points one of these utilities at /graphql.

Denial of service through nested queries, batching, and aliases

Because the client controls depth, it controls how much work the server does. The schema is a graph, and graphs have cycles. If a note has an author, and an author has notes, and each note has an author, you can write a query that descends through that relationship as far as you like:

query {
  note(id: 42) {
    author {
      notes {
        author {
          notes {
            author { name }
          }
        }
      }
    }
  }
}

Keep nesting and the resolver count explodes. Each level multiplies the work, and a sufficiently deep circular query can force the server to fetch and join enormous amounts of data from a single small request. The attacker spends a few hundred bytes; the server spends seconds of database time and a heap of memory. This is a denial of service that needs no botnet, just one well shaped query.

Batching and aliasing amplify it further. GraphQL lets you request the same field many times in one operation by giving each instance an alias. One HTTP request can therefore carry thousands of resolver calls:

query {
  a: note(id: 1) { title }
  b: note(id: 2) { title }
  c: note(id: 3) { title }
  d: note(id: 4) { title }
}

Extend that to thousands of aliases and one request becomes a bulk operation. Many servers also accept an array of operations in a single POST, a separate batching feature with the same effect. Either way, the unit a naive rate limit counts, the HTTP request, no longer matches the unit of work, the resolver call.

The fix is to stop reasoning about requests and start reasoning about cost. Query depth limiting rejects anything nested past a fixed level, which directly kills the circular query because a cycle has to nest to do damage. Complexity or cost analysis goes further: it assigns a weight to each field, sums the weight of the incoming query before executing it, and refuses queries over a budget. A list field that returns many items costs more than a scalar. A field whose resolver hits the database costs more than one served from memory. By scoring the query statically, the server can decline expensive shapes without ever running them, which is the only way to defend against a query you have not seen before. The OWASP GraphQL Cheat Sheet points at libraries like graphql-cost-analysis and graphql-validation-complexity for exactly this, alongside amount limits on list fields, server side timeouts as a backstop for anything that slips through, and a DataLoader to batch the resolver’s own database calls so legitimate nesting does not fan out into a query per node. The principle is to bound the work a single query is allowed to demand, regardless of how clever its shape is.

Broken authorization at the field and object level

This is where GraphQL hurts the most, and it follows directly from the resolver model. In a REST API the authorization check usually lives at the route: a middleware in front of GET /admin/users decides who gets in, and everything behind that one door is covered. In GraphQL there is no route to guard. There is one endpoint and a tree of resolvers, and each resolver is responsible for its own access control. Authorization is not enforced at the door; it is enforced at every field, and it only takes one unguarded field to leak.

Picture Acme Notes. The note resolver carefully checks that the caller owns the note before returning it. Good. But a note has an author, and the author type exposes email and phone, and the resolver for author was written assuming you only ever reach it through your own notes. An attacker reaches it through a shared note, or through a different relation entirely, and now reads contact details for users they have no business seeing. The guarded object was the note; the unguarded one was reached by traversing a nested relation off it. That is broken object level authorization, the same class the OWASP API Security Top 10 ranks first as API1:2023, and the same bug the web calls IDOR. GraphQL makes it especially easy to introduce because the relationships that let a client walk from one object to another are the entire point of the data graph.

In REST you guard the doors. In GraphQL there are no doors, only a graph, and every node has to guard itself. Miss one and an attacker walks in through a neighbor.

There is a second flavor of this that introspection sets up directly. Because the schema lists every type and every argument, an attacker can call an object by its identifier even when the UI never offers it. Suppose Acme Notes hides delisted notes from every listing, but the note(id:) field still resolves any id it is given. The listing is a presentation choice; the resolver is the real access boundary, and if the resolver only checks that the id is well formed rather than that the caller owns it, the hidden object is one direct query away. The fix and the failure are the same as above: the check has to live in the resolver, on the object, not in the layer that decided what to show.

The defense is per resolver authorization treated as a first class concern, not a sprinkle. Every resolver that returns sensitive data checks the caller’s right to that specific object, on both the nodes and the edges of the schema as the cheat sheet puts it. Centralizing this logic, rather than hand writing a check in each resolver, is what keeps one forgotten field from undoing the rest, and it is why teams move the decision into a shared authorization layer that every resolver consults rather than trusting each author to remember. If you want the broader pattern behind this bug, see our note on broken object level authorization and IDOR.

Injection still reaches the database through resolver arguments

GraphQL’s type system validates the shape of a query, not the safety of its values. A field that takes a String argument will reject a number, but it will happily pass an attacker controlled string straight through to whatever the resolver does next. If that resolver interpolates the argument into a database query, a shell command, or a NoSQL filter, you have the same injection you would have anywhere else, just arriving over GraphQL.

Suppose Acme Notes has a search field:

query {
  searchNotes(filter: "Marketing") {
    title
  }
}

If the searchNotes resolver builds its SQL by concatenating that filter string, an attacker sends a filter value crafted to break out of the string and the database executes it. The typed schema gave a false sense of safety here, because the type checked that filter is a string, not that the string is harmless. The fix is the ordinary one: parameterized queries and strict input validation inside the resolver, using GraphQL’s own scalars and enums to constrain arguments where you can, and never trusting an argument just because it passed type checking. The OWASP GraphQL Cheat Sheet is explicit that the type system is not an input validation layer.

Batching attacks that bypass rate limits on sensitive mutations

The aliasing trick from the denial of service section has a sharper edge when it is pointed at authentication. Rate limits on a login or a two factor check almost always count HTTP requests: five attempts a minute from this IP, then a lockout. Aliases let an attacker pack many attempts into one request, and if the limiter counts requests rather than operations, the limit never trips.

mutation {
  a: login(user: "victim", code: "0000") { token }
  b: login(user: "victim", code: "0001") { token }
  c: login(user: "victim", code: "0002") { token }
  d: login(user: "victim", code: "0003") { token }
}

Stack thousands of those aliases and a single request brute forces a four digit two factor code, or sprays a password list against a login mutation, all under one entry in the rate limiter’s ledger. The same applies to coupon redemption, password reset codes, and any mutation whose protection assumed one guess per request. PortSwigger documents this alias based rate limit bypass in detail, and it is one of the first things a GraphQL specific scanner checks for.

The defenses here are pointed. Count operations, not requests, so the limiter sees each aliased login as a separate attempt. Better yet, disable batching and aliasing on sensitive mutations entirely, or cap the number of aliases for a single field, so a login can appear once per request. The cheat sheet’s guidance is to prevent batching for sensitive objects like authentication and to enforce per object request rate limiting in code rather than only at the HTTP layer.

The defenses, gathered in one place

None of these issues is exotic, and the controls map cleanly onto them. Treat this as the checklist:

  • Restrict introspection in production. Disable __schema on public deployments, and turn off field suggestions too, since tools like clairvoyance rebuild the schema from suggestion errors alone. Keep introspection on only in environments you control.
  • Limit query depth and total cost. Reject queries nested past a fixed depth, and run cost analysis that weights each field and refuses anything over a budget before execution. Add amount limits on list fields and a server side timeout as backstops.
  • Use persisted queries or an allowlist. Register the exact queries your clients are allowed to send and reject everything else. An arbitrary query interface becomes a fixed, known set, which kills introspection, ad hoc nesting, and most batching abuse in one move.
  • Enforce authorization in every resolver. Check the caller’s right to each object on both nodes and edges, centralize the logic so it cannot be forgotten, and assume any field can be reached through a nested relation, not just through its obvious parent.
  • Validate arguments and parameterize. Never trust a value because it passed type checking. Parameterize database queries, validate inside the resolver, and constrain arguments with scalars and enums.
  • Disable batching where it bypasses rate limits. Count operations rather than requests, cap aliases per field, and turn off batching for authentication and other sensitive mutations.

For the canonical references, anchor on the OWASP API Security Top 10, which frames the authorization and rate limiting risks; the OWASP GraphQL Cheat Sheet, which gives the concrete server side controls; and PortSwigger’s GraphQL API vulnerabilities material, which walks the attacks hands on. For tooling, graphql-cop, InQL, and clairvoyance are the real, current utilities worth knowing.

The assumption that breaks

Step back from the individual bugs and one assumption is holding all of them up. GraphQL hands the client control over the shape of the response, the depth it descends to, and the volume of work a single request demands, and it assumes the client is not hostile. Every issue in this post is that assumption failing. Introspection assumes you only want the schema to build against it, not to map it for an attack. Nesting assumes you ask for what you need, not for a circular query that melts the database. Aliasing assumes you batch for convenience, not to brute force a login under one rate limit entry. The resolver model assumes each field is reached through a friendly path, not traversed from an unexpected neighbor.

That is what makes the graphql attack surface its own thing rather than REST with extra steps. The flexibility that makes GraphQL a good developer experience is precisely the flexibility an attacker uses, and the only durable fix is to bound what the client is allowed to ask for: restrict the schema’s visibility, cap the cost of a query, allowlist the operations, and check authorization at every node. The gap here is not a single broken function. It is the distance between what the server assumes a client will do and what a client can actually arrange, and that gap is the kind of thing you find by asking what each component trusts and why, rather than by scanning for a known bad string. It is exactly the kind of assumption an autonomous researcher built to test assumptions is meant to surface. Learn more about that approach on our about page.

Frequently asked questions

What makes the GraphQL attack surface different from a REST API?

A REST API exposes fixed routes that each return a fixed payload, so the server owns the response shape. GraphQL exposes one endpoint and a typed schema, and the client chooses which fields it wants, how deeply nested, and in what batch. That flexibility means the server has to answer whatever query arrives, which opens introspection recon, query based denial of service, and field level authorization gaps. PortSwigger walks these attacks hands on in its GraphQL API vulnerabilities material.

Why is GraphQL introspection a security concern?

Introspection is a built in reflection system. A single query against __schema returns every type, field, argument, deprecated operation, and hidden mutation, handing an attacker a full map of your data model in one request. Disabling it in production helps, but servers that return field suggestions in errors still leak field names, and the tool clairvoyance rebuilds the schema from those suggestions alone. The OWASP GraphQL Cheat Sheet recommends disabling introspection and suggestions on public deployments.

How do batching and aliasing bypass rate limits on a login mutation?

Rate limits usually count HTTP requests, but GraphQL aliases let one request carry many copies of the same field. An attacker can pack thousands of aliased login or two factor attempts into a single request, and a limiter counting requests never trips. The fix is to count operations rather than requests, cap aliases per field, and disable batching for sensitive mutations. This maps to the rate limiting risks in the OWASP API Security Top 10.

Why is broken authorization so common in GraphQL?

GraphQL has no route to guard. There is one endpoint and a tree of resolvers, and each resolver enforces its own access control, so it only takes one unguarded field, often reached through a nested relation, to leak data. This is broken object level authorization, ranked first in the OWASP API Security Top 10 as API1:2023. The fix is per resolver checks on both nodes and edges, centralized so a single field cannot be forgotten.