In 2019, HackerOne's GraphQL endpoint leaked data about private bug bounty programs to authenticated users who had no authorization to view them. These were not anonymous attackers: they were users with valid sessions, accessing fields that returned data from programs outside their access scope. Authentication worked. Authorization did not exist below the entry point.
GraphQL's resolver model creates an authorization surface that does not exist in REST. Each field is an independently resolvable data path. A missing check at the resolver level produces an IDOR that spans the entire schema, not just a single route.
One endpoint, unlimited attack surface: how GraphQL inverts the REST security model
A REST API with 50 routes has 50 explicit authorization boundaries enforced by middleware at the routing layer. Express middleware, Django decorators, NestJS guards: authorization lives alongside the route definition because route and resource are the same concept. Removing a check from a route is visible in the route's code.
A GraphQL API with 50 types and 200 fields has 200 potential authorization points. Each resolver is a separate function. The framework automatically protects none of them. Adding a new type to the schema is a data change; the attack surface that grows with it is the silent consequence. The OWASP WSTG identifies "the application enforces authorization at the field level" as a specific testing objective for GraphQL, precisely because the framework enforces nothing on its own.
GraphQL APIs in production typically validate "is this user authenticated?" at the gateway. They rarely validate "can this user read this specific field?" in each resolver that returns sensitive data. The gap between those two questions is where IDORs live. A query like { user(id: "victim") { email token privateData } } arrives authenticated at the endpoint and is routed to three distinct resolvers, each with its own authorization decision. If any one of them skips the context check, the data leaves.
REST does not have this structural problem because route and resource are the same object. In GraphQL, the route /graphql maps to no specific resource. Each field in the response is a distinct resource with its own resolver. Authorization applied to the endpoint protects the endpoint. Authorizing the data requires protecting the resolvers, field by field.
Introspection in production: handing attackers the schema blueprint
The introspection query is a native GraphQL feature: by sending { __schema { types { name fields { name } } } } to any endpoint, the server returns every available type, field, argument, and mutation. In development, this accelerates API exploration. In production, it is a map handed for free to any requester.
CVE-2025-53364 documents Parse Server: the GraphQL endpoint allowed unauthenticated public access to the full schema via introspection. No credentials required. Any request with the right query returned the entire data model. CVE-2023-47643 affects SuiteCRM: introspection enabled in production exposed CRM field names and relationships to any anonymous requester. Entity names, internal fields, relationships between types: the complete structure of a customer management system available without authentication.
HackerOne #291531 follows the same pattern in a distinct production environment: schema accessible via introspection query, revealing internal types that guided structured reconnaissance. HackerOne #1132803 documents an On Running endpoint with an enumerable schema via introspection in production, with no access restriction.
What attackers do with a complete schema: they identify sensitive types for authorization probing (User, Admin, Token, Order, Payment), enumerate permutations of available fields in those types, and build IDOR probes targeting specific fields. An exposed schema turns a blind attack into one informed by the exact structure of the system.
Disabling introspection reduces friction; it does not eliminate reconnaissance. The Clairvoyance tool reconstructs schemas from GraphQL field suggestion messages. When a client sends { usr { id } } and the server responds "Did you mean 'user'?", it confirms the existence of the user field. Clairvoyance automates this process, sending common words and variants, building the schema field by field from error suggestions. Disabling introspection while keeping field suggestions active delivers the same map with more friction. Complete mitigation requires disabling both.
Field-level IDOR: the authorization flaw that is structurally impossible in REST
REST APIs generate IDORs when the authorization check is missing from the route. This is detectable: the route exists, the check should exist, it simply was not implemented. In GraphQL, the structure of the problem is different. The resolver exists; authorization is not absent due to an isolated oversight — it is absent because the pattern requires it in no field by default.
HackerOne #489146 is the reference case for its specific irony: HackerOne's own GraphQL endpoint exposed confidential data about private bug bounty programs to authenticated users without access rights. The users were legitimate. The query was valid. The resolver returned data from programs outside the user's access scope because no authorization check existed in the resolver. Authentication worked. Field-level authorization did not.
HackerOne #885539 documents Twitter: private list members exposed via a GraphQL field with no access control in the resolver. The list was private. The field returning its members did not verify whether the requesting user had access to that list. The access check existed in the REST equivalent. It did not exist in the GraphQL resolver.
HackerOne #614355 is the cleanest case available. GitLab had access controls enforced on the REST route returning namespace data. The GraphQL namespace resolver returned data from private namespaces (groups and projects) to any authenticated user, without permission checks. Same data, two access paths, two independent authorization decisions. The REST endpoint blocked. The GraphQL resolver delivered the data.
The pattern scales with schema size. REST IDOR is a missing check on a specific route. GraphQL IDOR is a missing check in the resolver, multiplied by every field in the schema that returns user-bound data. Escape.tech documented concrete cases: PayPal with unauthorized assignment of secondary users from other accounts via GraphQL mutation; Shopify with session expiration through object reference manipulation; Vimeo with password reset via unverified object reference. GraphQL aliases like { targetData: user(id: "victim-id") { email token } } bypass field name filters and rate limiters operating at the field level, while executing the same unauthorized resolver under a different name.
Batching attacks: how GraphQL turns rate limiting into theater
GraphQL allows sending multiple operations in a single HTTP request as an array of queries or mutations. Rate limiting by HTTP request count becomes irrelevant: one request equals N operations, where N is unlimited by default in most implementations.
Checkmarx demonstrated brute-forcing 100 common passwords in a single batched request. A valid session token was extracted without triggering any rate limit configured by IP or request. The demonstration used a real login endpoint, sending 100 distinct mutations in a single POST. The server processed all 100; the rate limit counted 1.
The calculation is straightforward. A rate limit of 100 req/min with unlimited batching allows 100 x N operations per minute, where N is the batch size. The effective limit per operation approaches zero as N grows, and no server default limits N in most GraphQL implementations in production.
OTP bypass via batching is more critical. A six-digit token has 1,000,000 possible combinations. All of them can be submitted in batched mutations in a handful of requests. The server processes each variant against a rate limit counter with a value of 1 per request. A resolver-level attempt lockout mechanism would be needed to block this; HTTP rate limiting does not.
Object enumeration follows the same pattern: batched queries with sequential user IDs extract complete profiles while the rate limit counts one request per batch. OWASP recommends resolver-level rate limiting, counting field resolutions and query complexity, not HTTP requests. Libraries like graphql-query-complexity and graphql-depth-limit implement this control. Most production APIs do not use them.
Argument injection: why SQL and NoSQL injection survive the GraphQL abstraction
The same model that fragments authorization across N resolvers fragments the sanitization surface the same way — every resolver that constructs queries from arguments is an independent injection point.
GraphQL does not sanitize arguments: it passes them directly to resolvers, which build database queries from them. The abstraction creates false confidence. Developers assume GraphQL's type system prevents injection. Types validate format, not content.
HackerOne #435066 documents SQL injection on the GraphQL endpoint via the embedded_submission_form_uuid parameter. The argument was typed as String, which GraphQL accepted without content validation. The value reached the SQL query unmodified, enabling data extraction from public and secure schemas. An argument typed as String accepts "abc123" and "abc123' OR '1'='1" with equal validity; the type does not distinguish between them.
Praetorian documented production GraphQL APIs where resolver functions passed filter arguments directly to ORM queries without parameterization. The pattern recurs: the resolver receives filter: { email: userInput } and passes the value directly to db.find({ email: userInput }). If userInput is {"$ne": null}, MongoDB returns every record in the collection.
NoSQL injection via GraphQL object arguments follows the same logic. The query { users(filter: { email: {"$ne": null} }) { id email } } is valid if the schema accepts input objects without field-level content validation. OWASP states explicitly: when data from a GraphQL client is accepted without server-side sanitization, SQL and NoSQL injection is possible. GraphQL has no native input validation. The graphql-constraint-directive is the community's solution for this gap, and most production APIs do not implement it.
Testing methodology: from schema reconnaissance to field-level IDOR scanning
GraphQL security assessment follows a fixed sequence because each phase reveals targets for the next. Schema reconnaissance informs which fields to test. Type mapping informs which arguments to inject. Authorization testing reveals which resolvers lack checks.
Step 1: test introspection via POST to /graphql and /api/graphql with {"query":"{ __schema { types { name fields { name } } } }"}. If blocked, run Clairvoyance against field suggestions in error responses to reconstruct the schema by inference.
Step 2: map IDOR candidate types in the schema — types with ID arguments, fields returning PII (email, token, payment, address), and mutations that modify objects identified by another user's ID. Step 3: test field-level authorization by querying sensitive fields as a lower-privilege user; use fragments and aliases to bypass field name filters enforced at the gateway.
Step 4: test batching with an array of login mutations using distinct passwords, and an array of object queries with sequential IDs for horizontal enumeration. Step 5: inject into string arguments with SQL payloads (' OR '1'='1) and NoSQL payloads ({"$ne": null}), and into integer arguments with out-of-range IDs and negative values for boundary testing.
intel.mago.team (MAGO team tool) probes GraphQL endpoints as part of the API surface scanner, covering introspection detection, schema enumeration, and basic access control mapping in an automated workflow.
The difference between a REST API and a GraphQL API is not syntax: it is the authorization model. REST allows centralizing authorization at the routing layer. GraphQL pushes that decision into each resolver individually, with no guarantee that any developer remembered to implement it. Generic tools that scan REST APIs find unprotected endpoints. GraphQL requires probing each field independently — specialized scanners (escape.tech, StackHawk) do this, but rarely appear in standard DAST pipelines. The question is not whether the API is authenticated, but whether it is authorized at the field level. Those are not the same question, and the gap between them is the surface that reports #489146, #885539, and #614355 document.
Top comments (0)