PXL Security LTD, Sofia, Bulgaria Offensive security since 2014[email protected]
GraphQLAPIWeb Security

GraphQL security: introspection off is not schema hidden

By PXL Security18 March 202515 min read

GraphQL gives clients enormous flexibility — and hands attackers the same. Teams often disable introspection and consider the API hardened. On our tests, that's rarely the end of the story: the schema leaks through other doors, and GraphQL's flexibility makes resource exhaustion easy.

Schema disclosure without introspection

Introspection is the official way to ask a GraphQL API to describe itself. Turning it off removes the obvious route — but validation errors often give the schema away anyway. Send a slightly wrong field name and many servers respond with a helpful "did you mean …?" suggestion. Iterate that, and an attacker reconstructs query and mutation names and input shapes without introspection ever being enabled. In production, suppress field suggestions and return generic validation errors.

Resource exhaustion by design

GraphQL lets a client ask for exactly what it wants — including far too much. Three patterns turn one request into heavy server work:

  • Unbounded pagination — asking for every record at once.
  • Field aliasing — requesting the same expensive field many times under different names in one query.
  • Operation batching — sending an array of operations so one HTTP request triggers many.

Combined, a single authenticated request can force repeated full-dataset scans and degrade availability for everyone. We've demonstrated one query returning thousands of rows this way.

And the usual suspects still apply

GraphQL doesn't exempt you from authorization. Object-level access control (BOLA/IDOR) is just as common here — resolvers that return an object without checking the caller owns it. Every resolver that returns sensitive data needs its own ownership check; a gateway-level "is authenticated" gate is not enough.

Hardening checklist

  • Disable introspection and suppress field-suggestion errors in production.
  • Enforce maximum page sizes, query depth and query cost; reject oversized batches.
  • Authorize at the resolver, per object, every time.
  • Rate-limit by principal, accounting for batching.

Batching and the authentication layer

One more GraphQL-specific trap worth calling out: operation batching can amplify attacks on authentication itself. By bundling many login attempts or token requests into a single HTTP request, an attacker slips straight past rate limits that count requests rather than operations — turning one call into hundreds of guesses. Rate-limit by operation and by principal, not by HTTP request, and apply the same brute-force and credential-stuffing protections to GraphQL mutations that you would to a REST login endpoint. The flexible query language doesn’t exempt you from the basics; it just gives attackers a new way to evade them.

Depth and cost limits in practice

Query depth and cost limits aren’t optional niceties for a mature GraphQL API — they’re the controls that stop one crafted query from doing enormous work. Set a maximum depth, assign a cost to expensive fields, and reject anything that exceeds the budget. Then test it the way an attacker would, with deliberately deep and expensive queries, to confirm the limits actually fire rather than merely existing in config.

Persisted queries and allow-listing as a structural control

Most of the abuse covered so far shares one root cause: the server will execute any syntactically valid query a client sends. Rate limits, depth caps and cost ceilings all try to tame that openness after the fact. Persisted queries invert the model. Instead of accepting arbitrary operation text, the server keeps a registry of approved operations, each identified by a hash, and the client sends only that identifier plus variables.

{ "id": "<sha256-of-approved-operation>", "variables": { "first": 20 } }

If an identifier is not in the registry, the request is rejected before parsing or planning. Field suggestions, alias-based amplification, deeply nested probes and introspection-style enumeration all collapse, because none of those operations were ever registered. This is why allow-listing is the strongest single control for a first-party API where the client set is known: the schema can remain internal, yet attackers cannot submit operations of their own design.

The distinction worth drawing is between automatic persisted queries (APQ), a performance optimisation that still registers whatever a client first sends, and a curated allow-list built from your own clients at build time. Only the latter is a security control. APQ left in its default register-on-first-use mode is not an allow-list.

How to harden this

  • Generate the allow-list from your own client builds in CI and ship it to the gateway; reject unknown operation IDs outright.
  • Do not let production register new operations on demand — separate the convenience of APQ from the guarantee of allow-listing.
  • Keep variables validated and bounded even for approved operations; an allowed query with an unbounded first value is still an allowed query.
  • For public or third-party APIs where allow-listing is impractical, fall back to layered cost and depth analysis as the primary defence.

How depth and complexity are actually computed

Depth and cost limits were mentioned earlier as a defence; it is worth seeing how a cost is derived, because a naive implementation is easy to walk around. Depth analysis counts nested selection levels and rejects an operation past a threshold. It is cheap but blunt: a shallow query that retrieves a list field returning thousands of objects, each with a few scalars, has low depth and high real cost.

Complexity analysis assigns each field a weight and multiplies child cost by the pagination argument that governs how many parents exist. A field returning a connection with first: 100 should contribute roughly one hundred times the cost of its sub-selection, not one. The total is computed statically, before execution, from the query and its variables, and compared against a budget.

cost(list field) = count_argument x (1 + sum of cost(child fields))

The common mistakes are counting fields without multiplying by list size, ignoring aliases so the same field counted ten times scores as one, and reading the count from a default rather than from the actual variable supplied at runtime. Any of these turns a cost limit into decoration.

How to harden this

  • Weight list and connection fields by their effective pagination argument, resolved from variables, not from schema defaults.
  • Count aliased duplicates of a field as separate cost, since the executor will resolve each one.
  • Enforce a maximum page size per connection so an unbounded first cannot be supplied in the first place.
  • Reject on the static estimate before execution begins, and additionally meter real resolver work to catch estimates that proved optimistic.

Injection reaching through resolvers to the data layer

GraphQL is a transport and a type system; it is not a sandbox. A resolver is ordinary server code, and a field argument is attacker-controlled input that frequently ends up in a database query, a search filter, a file path or a downstream service call. The type system constrains the shape of an argument — a String is a string — but it says nothing about its content, so classic injection classes travel straight through a well-typed schema.

Two patterns deserve particular suspicion. First, filter and sort arguments that map onto query-builder clauses: a free-form filter object that is spread into a query lets a caller influence predicates, ordering or even field selection at the data layer. Second, pass-through identifiers concatenated into raw queries inside a resolver, where the GraphQL layer adds a false sense of safety because the value was "typed".

How to harden this

  • Treat every resolver argument as untrusted; use parameterised queries and bound identifiers at the data layer, never string concatenation.
  • Model filters as explicit, enumerated input types with known operators, rather than accepting arbitrary objects that are spread into a query.
  • Validate scalar content, not just scalar type — length, character set and format — with custom scalars where it helps.
  • Apply least-privilege database credentials per service, so a resolver injection cannot reach data the service never legitimately touches.

Error handling and information-leakage hardening

Errors are the quietest disclosure channel in a GraphQL API. Default error handling in many servers returns stack traces, resolver names, underlying exception messages and the offending path in the response. Combined with the field-suggestion behaviour discussed earlier, verbose errors let an attacker reconstruct internals, learn which backend a field talks to, and distinguish "not authorised" from "does not exist" — a difference that itself leaks structure.

GraphQL also returns partial results: a response can carry data for the fields that resolved and an errors array for those that failed. That is useful, but it means an authorisation failure on one nested field must not spill the wider object, and an error message must not describe why a resolver failed in terms an attacker can mine.

How to harden this

  • Return generic, stable error messages to clients and log the detail server-side against a correlation ID; never serialise stack traces or exception text in production responses.
  • Make authorisation failures indistinguishable from absence where the existence of a resource is itself sensitive.
  • Strip or mask the extensions block that some frameworks use to attach debug metadata before responses leave the gateway.
  • Alert on error-rate spikes and on bursts of field-suggestion-triggering typos, which often signal schema mapping in progress.

Is your GraphQL API as closed as you think?

Our API penetration testing probes schema disclosure, resource exhaustion and resolver-level authorization — the issues scanners miss.

Scope an API test