API Security

GraphQL Security: Vulnerabilities From an Attacker's View

By handing clients the freedom to shape their own queries, GraphQL buys a great deal of developer convenience. That same freedom opens a surface on the security side that REST did not accustom us to: one endpoint, a self-describing schema, and query shapes the server never anticipated.

9 min read Vulnerability classes

Why GraphQL's security profile differs

In REST each endpoint is a separate path; access control, rate limiting and monitoring are largely built on path and method. In GraphQL almost all traffic flows to a single path, usually with a single body type. That has three direct consequences:

  • Path-based controls go blind. A rule of "ten requests per minute to this endpoint" cannot see a single request that does a hundred units of work.
  • The server does not know the shape of the data it will return. Access control has to descend from the endpoint level to the field and object level.
  • The schema describes itself. Discovery is very cheap for an attacker; types, relationships and mutations all sit in one place.

Discovery: introspection and schema leakage

Introspection left enabled in production hands over the full map of types and fields in a single request. That is not a vulnerability in itself — the real problems live in authorisation — but it visibly lowers the attacker's cost and is usually the starting point for every other finding.

Disabling introspection does not fully hide the schema. Helpful error messages generated by the server (the "did you mean" suggestions on a typo) allow field names to be confirmed one at a time. Likewise, query strings inside client bundles, network traffic in developer tools and source maps expose much of the schema anyway.

Hiding the schema is a delay measure, not a defence. An API that looks safe with introspection disabled must also be safe with it enabled. Otherwise what you have is security through obscurity.

The real risk: field- and object-level authorisation

The vast majority of high-impact findings in GraphQL fall under authorisation. The recurring patterns:

  • Authorisation stopping at the top level. The root query is authorised, but nested fields do not go through the same check. A user can start from an object they legitimately reach and traverse relationships to objects they should not — for example moving through the "owner" relation of a record in their own team into another user's private fields.
  • Direct access by object identifier. In schemas that expose a generic node-fetching field keyed on global identifiers, a guessable or leaked identifier with no ownership check produces classic broken object-level authorisation.
  • Mutations reviewed less carefully. Read paths get attention while role checks are skipped on the write side. Especially where administrative operations live in the same schema.
  • Sensitive fields present in the schema. Password hashes, internal notes or email addresses defined on a type and merely not rendered by the client UI. The schema is the server's contract, not the interface's.

The right place is to build authorisation at the resolver level and, where possible, in the data access layer: every field makes its own decision with knowledge of the calling context. Schema-level directives are valuable because they are readable, but they cannot carry ownership decisions that depend on business logic on their own.

Query cost and resource exhaustion

If the schema contains cyclic relationships — and it almost always does — a client can make the server do an enormous amount of work with a small piece of text. Nested relations produce a multiplier at every level; a few hundred bytes of query can turn into thousands of database queries.

What needs measuring here is not request count but cost:

  • Depth limit: reject anything beyond a given nesting level.
  • Complexity/cost analysis: assign a cost to each field, compute the query's total before executing it, and reject anything over budget.
  • Mandatory pagination: capped page sizes on list-returning fields; no unbounded list fields.
  • Timeouts and concurrency limits: stop a single query from exhausting the pool.
  • Persisted queries: in production, accept only pre-approved query identifiers. This is the single most effective measure, closing the free-form query surface entirely.

Bypassing rate limits with aliases and batching

This is the class most specific to GraphQL and the easiest to miss. A client can call the same field many times within one query under different aliases, and many servers additionally accept multiple operations as an array in a single HTTP request.

The result: a rate limit that counts HTTP requests cannot see hundreds of attempts in a single request. Authentication, one-time code verification, coupon redemption and username enumeration all become effectively open to brute force through this path.

On the defensive side:

  • Count rate limits by resolver invocation or cost unit, not by HTTP request.
  • Cap the number of aliases per query and the repetition count of a single field.
  • Disable operation batching in production, or bound the array length.
  • Use separate, strict per-account and per-IP counters on sensitive mutations, and monitor failed attempts specifically.

Behind the resolver: injection and SSRF

GraphQL is a query language, but it is not a security layer for the data tier behind it. If arguments flow straight into a database query, a file path or an outbound request, every classic injection class applies unchanged.

  • Database injection: filter or sort arguments concatenated into query text. The type system does not validate string contents.
  • Server-side request forgery: mutations that take a URL (file import, webhook registration, preview generation) can be pointed at the internal network.
  • File upload: in schemas using the multipart upload extension, type, size and storage location controls.
  • Custom scalar types: this is where validation logic is written, and it is frequently incomplete; the presence of a type name does not mean the value was validated.

Transport layer: CSRF, subscriptions, error messages

  • CSRF. If the server accepts queries over GET or with simple content types and authentication is cookie-based, cross-site request forgery becomes possible. Write operations should be accepted only via POST with application/json, and cookies should carry an appropriate SameSite setting.
  • Subscriptions. On WebSocket connections authentication is often performed only at connection time. When a session is revoked the open connection must terminate too, and origin checks must not be skipped.
  • Error messages. Stack traces, internal field names and database errors should not reach the client in production. These messages feed both schema discovery and the feedback loop of injection attempts.
  • Developer interfaces. Query playgrounds left enabled in production make discovery and experimentation free.

Defence checklist

Measure Class it closes
Field- and object-level authorisation at the resolverBroken object/field authorisation
Query depth and cost budgetResource exhaustion, denial of service
Persisted query allowlist (no free-form queries in production)Discovery + cost + unexpected shapes
Alias and batch limits, cost-based rate limitingRate limit bypass, brute force
Mandatory, capped paginationBulk data extraction
Introspection and suggestion messages disabled in productionEase of discovery (not a defence on its own)
Simplified error messagesInformation leakage
POST + JSON required, SameSite cookiesCSRF
Arguments used through parameterised queriesInjection
Allowlisted destinations on outbound requestsSSRF

Summary

My order when testing GraphQL is this: derive the schema first, then push hard on authorisation — because nearly all high-impact findings live there. Then measure cost and rate-limit behaviour, and only at the end look at the classic classes behind the resolvers.

On the defensive side a one-sentence summary is possible: GraphQL's flexibility should belong to the client, not the server. Unless what can be returned, how much cost can be incurred and who can see what are bounded explicitly and in advance on the server side, the schema itself is the attacker's best documentation.

References

  1. OWASP GraphQL Cheat Sheet: cheatsheetseries.owasp.org
  2. OWASP API Security Top 10: owasp.org/API-Security
  3. GraphQL Specification — GraphQL Foundation.
  4. PortSwigger Web Security Academy — GraphQL API vulnerabilities: portswigger.net/web-security/graphql