High to Critical GraphQL introspection and alias-batching abuse
At a glance
- Class
- Broken object-level and object-property-level authorisation in a GraphQL API, amplified by alias batching, with schema disclosure as the discovery accelerant
- Severity
- High when a low-privilege account reads another person's sensitive fields and the same record is confirmed from a second account. Critical only where the same path crosses tenants or reaches workforce scale, evidenced by the record count observed in a single response rather than inferred. Introspection on its own is Informational to Low, and the alias-batching rate-limit bypass on its own is Medium.
- Prerequisites
- An authenticated account, a schema edge from an object that account may read to the sensitive person type, a source of victim identifiers, no server-side cap on nodes returned, and no cost scoring before execution
- OWASP API Security Top 10
- API1:2023 broken object level authorisation, API3:2023 broken object property level authorisation, API4:2023 unrestricted resource consumption, API8:2023 security misconfiguration
- CWE
- CWE-862 missing authorisation on nested and field resolvers as the primary defect, CWE-639 authorisation bypass through user-controlled key for the identifier-driven traversal, CWE-200 exposure of sensitive information for the schema disclosure, and CWE-770 missing throttling scoped to the resource-consumption arm only
- Reachable from
- Schema disclosure: any client that can reach the endpoint. Authorisation traversal and bulk read: an authenticated low-privilege account permitted to read the parent object. Check whether the server also accepts queries over GET, which changes both reachability and the caching picture.
- Who is affected
- Every employee whose record is in reach, across whichever sensitive fields the schema exposes on that person type, commonly compensation, government identity and tax numbers, payment instructions and emergency contacts, enumerated from the fields actually returned and stated alongside the observed record count
- Why it is missed
- Default HTTP access logging records one POST to one path, so request-count limits, per-endpoint rules and volume alarms all stay quiet
What it is
GraphQL introspection and alias batching are two ordinary features that combine into a bulk data export. The server describes its own schema to anyone who asks, then resolves the same field hundreds of times inside a single request, so one POST can read far more data than the application itself would ever display. The defect that makes the export possible is neither of those features: it is authorisation enforced on the top-level query a role may call rather than on the fields that return the data, which is broken object-level and object-property-level authorisation. Introspection is the accelerant, because on an HR or payroll platform the schema listing is a map of where pay, tax identifiers and bank details live and which argument selects one person's record. Alias batching is the amplifier, because a GraphQL document may request the same field many times under different aliases and the server resolves each occurrence independently.
The commercial problem is that API protection built around REST treats the URL path as the unit of control. A gateway in front of GraphQL sees one path and one method for the entire API surface, so per-endpoint rate limits, per-endpoint authorisation rules and per-endpoint logging all collapse onto a single line, and the gateway cannot tell a payslip lookup from a full workforce export. That holds unless a GraphQL-aware proxy or router is deployed, in which case per-operation policy and telemetry are available and should be checked for rather than assumed absent. Payroll and HR systems hold some of the densest personal data a company keeps about its own staff, and where the platform is sold as SaaS it is normally multi-tenant, which means the same traversal that reads a colleague's record is also the test of whether it reads another employer's. None of this requires an unusual configuration: introspection is frequently left reachable in production, and authorisation is frequently implemented at the root query rather than on the fields that return data. The second of those is a common implementation pattern rather than a framework default, since GraphQL libraries ship with no authorisation at all, so both need verifying per stack and per version rather than assuming.
How the attack unfolds
Mechanism, not a recipe. Reproduction detail stays in client reports, because a page that hands a reader a working attack is a liability.
The entire GraphQL API sits behind one endpoint
GraphQL normally exposes a single route, commonly /graphql or /api/graphql, through which every read and write the application supports is performed. An attacker confirms it by watching the application's own network traffic or probing the usual paths, and the response shape and error text identify the server implementation even when the path has been renamed. Whether the server also accepts queries over GET matters here, because it changes what can be reached without a form post and what a cache or a browser will do with the response.
From there the work is not endpoint enumeration but understanding one door.
Introspection returns the schema to anyone who asks
One introspection document asks the server to describe its own type system, and a server that has left the feature enabled in production answers in full. The reply is the internal data model: which object holds pay, which field holds a bank account, which argument selects an employee, and which mutations change roles or payment instructions. Where introspection is switched off but field suggestion in error responses is not, type and field names can still be reconstructed by probing names and reading what the server offers back, and the schema the front end was built against is often recoverable from web bundles or a mobile binary.
The schema lists candidate paths, it does not show where the authorisation check stops
Introspection returns types, fields, arguments and descriptions. It does not say where authorisation is enforced, whether a traversal edge from a permitted object to the payroll type exists, or whether a parent resolver returns objects outside the viewer's scope, so every candidate path is a hypothesis that has to be tested with a low-privilege account. What makes the test worth running is that authorisation in GraphQL is often applied to the top-level query a role is permitted to call, while the resolvers for nested child fields trust whichever parent reached them, so a field refused when asked for directly can sometimes be read as a child of an object the account is allowed to see, for example a team or department whose member type carries payroll and identity fields.
Reaching another person's record that way is broken object-level authorisation, commonly called IDOR, or BOLA in the OWASP API list. Sensitive fields returned on a record the account may otherwise see is a separate class, broken object-property-level authorisation, and the two need separate evidence. If no such edge exists, or every parent is scoped to the viewer, the chain terminates at this step.
One record proves the traversal, and the identifier space decides whether it scales
Reading a single colleague's record through that path confirms both the authorisation gap and the exact response shape to work with. Scale is a separate question and a hard precondition for everything after it, because a per-record lookup needs identifiers to iterate over. Directory search, org charts, team listings, approval queues and notification payloads commonly hand those out to ordinary users, and short or sequential identifiers can simply be walked.
Where identifiers are random, long and exposed nowhere a low-privilege account can read them, the finding stays a single-record authorisation issue rather than a bulk export.
A list field or a wall of aliases turns one request into a bulk read
There are two shapes. Where the schema exposes a list or connection field over the person type, the bulk read is one paginated query with a large page size, and no aliasing is needed. Where only a per-identifier lookup exists, the same field is repeated many times in one document under different aliases, and the server resolves every occurrence, so a document carrying hundreds of aliased lookups performs hundreds of resolver executions and up to the same number of record reads, fewer where a per-request batching loader coalesces them.
That coalescing works in the attacker's favour, because it makes the export cheaper and quieter rather than slower. A limiter that counts requests per minute sees one request and a depth limiter sees a shallow document, because the growth is in width rather than nesting, and where the server also accepts an array of operations in a single body the multiplier applies again on top of that.
Default access logging records one POST and nothing about what it read
What reaches default HTTP access logging is one successful response to one URL with one method, with no operation name, alias count or list of records touched, and on an endpoint that legitimately returns large reports and dashboards response size is rarely a strong enough anomaly to notice. GraphQL-native usage reporting, resolver tracing and datastore audit or slow-query logs do capture that detail where they are deployed, so the honest position is that the export is unobserved only until someone establishes which of those exist. Where none do, detection and any later investigation both start from a near-empty evidence trail.
Business impact
The dataset at risk is the payroll and HR record in full: salary and bonus history, national identity and tax reference numbers, bank account and sort code, date of birth, home address, next of kin and dependants, right-to-work and immigration documents, and in many platforms sickness absence, occupational health notes and grievance records. Where health data, trade union membership or similar fields are present, that is special category data under Article 9 of the UK and EU GDPR, which carries a higher standard for lawful processing and a worse position after loss. Because payroll platforms sold as SaaS are normally multi-tenant, the scope question is not only how much of one workforce a single credential can reach but whether the same nested path crosses from one employer's records into another's.
A bank account and sort code attached to a verified identity and a known salary is directly monetisable, and payroll diversion fraud depends on exactly that pairing: change where a salary is paid and the loss lands in the next pay run, after the money has gone. Regulatory duties start quickly, since Article 33 of the GDPR requires the supervisory authority to be notified without undue delay and, where feasible, within 72 hours, and Article 34 requires the affected individuals to be told where the risk to them is high, which here can mean staff at every employer in reach rather than a handful of accounts. For a vendor selling into enterprises the contractual consequences usually arrive before the regulatory ones, in the form of breach notification clauses, reopened security questionnaires, an ISO 27001 or SOC 2 report that no longer describes the control environment, and renewals that stall while the customer's own risk team reassesses.
Operationally the cost is driven by how thin the evidence is. A batched read is close to indistinguishable from ordinary traffic in default logging, so an investigation often cannot establish how many records actually left, and the defensible assumption becomes that everything the credential could reach was taken, which widens notification to the full population rather than a provable subset. Remediation on the employer side is manual and time-bound, because bank details for an entire workforce have to be re-verified through a channel the attacker does not control before the next payroll cut-off, while a national identity number cannot be rotated at all, so that part of the exposure is permanent for the individual. The engineering fix is structural rather than a patch, touching the authorisation model, the rate limiting strategy and the logging pipeline at the same time, which is why it tends to land across several sprints rather than one.
How it is found
| Signal | How it is confirmed |
|---|---|
| Does the API publish its own schema? | We send an introspection document and read the reply in full. Where introspection is refused, we probe field and type names to establish whether error-message suggestion still discloses them, and we read the client bundles and any mobile binary for the schema the front end was built against. |
| Is authorisation enforced on fields, or only on root queries? | We take a sensitive field that is refused on a direct query and request it again as a child of an object the test account is permitted to read, using the lowest-privilege role available. We run the same path from a second controlled account to prove the record belongs to someone else. We keep object-level reach, meaning another person's record, informally IDOR or BOLA in the OWASP API list, separate from object-property-level exposure, meaning sensitive fields returned on a record the account may otherwise see, because each is its own class and each needs its own evidence. |
| Do the rate limits measure work, or count requests? | We issue the same quantity of record reads two ways, spread across many small requests and packed into one document as repeated aliases, using distinct identifiers across the aliases so a per-request batching loader or response cache cannot memoise the lookup and answer cheaply. We judge the result at body level: every alias key present under data with non-null record content and no matching entry in the errors array, never on HTTP 200 alone, since GraphQL conventionally answers 200 with a partial body. Both arms run against the same identity in the same time window so limiter state is comparable. This is resource-consumption testing and needs written authorisation before it starts: we begin at a low alias count, step up only while response times stay nominal, stop at the first sign of degradation, and record the alias count at which the limit engaged rather than probing for a maximum. |
| Do the limits bound query width as well as depth? | We send shallow documents that repeat the same field widely against servers that already enforce depth and timeout limits, then read the shape of the rejection rather than whether it passed. A cost limiter that scores before execution rejects at validation time: no data key, no partial results, no resolver side effects, identical on the first attempt and independent of server load. A runtime-only control yields partial data, a timeout, or latency that scales with alias count, so we compare the rejection shape and the latency curve. The same written-authorisation and step-up rules apply as above. |
| Are the records real, distinct, and actually someone else's? | For a sample of what comes back, we verify from a second controlled account or with the platform owner that the identifiers map to distinct real subjects and that the sensitive fields hold real values rather than masked, tokenised or seeded test content. We also check that a batched read returned distinct records rather than the same record repeated under many aliases. We then count the distinct records returned in one request and state that number as the impact, instead of inferring scale from the schema. |
| Would a batched read show up in telemetry at all? | This step needs log access and the platform owner's participation, so it is white-box work rather than a black-box probe. Where access is granted we review the access logs, APM traces and SIEM events for our own test traffic to establish whether the operation name or hash, alias count, resolver count and identifiers touched were recorded, or only the URL, method and status. Where access is not granted, we drop the detection-gap conclusion rather than asserting it. |
How it is fixed
| Control | What makes it hold |
|---|---|
| Authorisation at the resolver that returns the data | Every field returning personal data checks the viewer's permission for that specific object rather than trusting the parent that reached it, and denies by default when no rule matches. Enforce it at a single data-access boundary rather than as per-resolver checks that drift as the schema grows, and cover global-identifier lookup paths, meaning node-by-id style resolvers, along with interface and union fields, which otherwise route around per-type checks. Batching loaders must carry the viewer context so a cached object is never served to a different requester, nested paths need their own test cases, and denial responses must be indistinguishable for non-existent and unauthorised objects, or the error itself becomes an existence oracle that restores enumeration after the data is protected. |
| Cost scoring before execution | Each document is scored on resolver count, repeated aliases and duplicate fields, skip and include branches, requested page sizes and nesting, then charged against a budget and rejected when the budget is exceeded. The scoring has to happen before resolvers run, because a limit that only measures elapsed time or request count leaves width unbounded. Charge the score against the whole HTTP body, batched operation arrays included, since a per-operation charge lets one request buy several budgets, and aggregate per tenant and per source as well as per identity. Pair it with account-creation controls, because a per-identity budget is bypassed by registering more identities wherever signup is self-service. |
| Allow-listed persisted operations | Clients send an identifier for a document registered at build time and the server executes nothing else, which removes arbitrary aliasing and arbitrary field selection. It bounds query shape rather than access: variables stay attacker-controlled, so a registered query that takes an employee identifier is still exploitable for object-level authorisation abuse and a registered list query is still exploitable by varying page size and cursor, which is why the per-object authorisation above remains mandatory. A build-time allowlist is also a different mechanism from automatic persisted queries, which register client-supplied documents at runtime and enforce no allowlist at all. The control holds only when the fallback that accepts unregistered documents is closed in production. |
| Caps on document width and HTTP-level batching | Disable HTTP batched operation arrays unless a client genuinely requires them, and enforce explicit per-document caps on alias count, duplicate-field count and total selections, rejected at validation time before execution. Without these, the multiplier that makes a single request read thousands of records stays open even where depth and timeout limits are already enforced. |
| Server-side node caps and operation-level logging | Cap total nodes returned per request and per batched body rather than only per field, and apply the cap to nested connections, because several aliased list fields each capped at the field maximum still multiply to that many times the cap in one response. Record the operation name or hash, alias and resolver counts, response size and the object identifiers touched, alert when one request reads an unusual number of records, and track cumulative unique records read per identity over time, since slow extraction never trips a single-request threshold. |
| Introspection and field suggestion disabled in production | Both the introspection query and error-message field suggestion are turned off so the schema is not handed out on request. Treat this as friction rather than a control, because the schema is also recoverable from web bundles and mobile binaries, which means every control above has to hold even once an attacker knows the name of every type and field. |
Questions
What is GraphQL introspection and why is it a security risk?
Introspection is a built-in GraphQL query that asks the server to describe its own schema: every type, field, argument and mutation it supports. It is a developer convenience, but on a production API it hands an attacker the data model, including which field returns salary or bank details and which argument selects a single record. It is schema disclosure rather than an access path, so on its own it rates low. What it does is remove the discovery work before an authorisation gap is tested.
Is disabling introspection enough to secure a GraphQL API?
No. Disabling introspection removes a map, not an access path. Schema names are also recoverable from JavaScript bundles, mobile binaries and error-message field suggestions, and every query that worked before still works afterwards. The controls that hold are authorisation on each resolver that returns data, cost scoring before execution, and allow-listed operations.
How do attackers use GraphQL aliases to bypass rate limiting?
A GraphQL document can request the same field many times under different aliases, and the server resolves each one, so hundreds of record lookups travel inside a single HTTP POST. A rate limiter that counts requests per minute sees one request and allows it. Aliasing is the fallback for lookup fields that take one identifier at a time: where the schema exposes a list or connection field, the same bulk read is simpler still with one paginated query at a large page size.
Does a GraphQL depth limit stop alias batching, or do you need query cost analysis?
A depth limit does not stop it. Query cost analysis scores a GraphQL document before any resolver runs, counting resolver invocations, repeated aliases and duplicate fields, requested page sizes and nesting, and charges the total against a budget for the caller, so a document that would read thousands of records is rejected at validation rather than answered. Depth limiting bounds only how deeply a query nests, and alias batching grows sideways instead: a document carrying hundreds of aliased sibling fields can stay two levels deep. Depth limits are worth keeping for recursive queries, but width and total cost need a limit of their own.
Can one GraphQL request export an entire employee database?
Yes, where field-level authorisation is missing and the limits count requests rather than work done. Aliasing a permitted employee lookup across a list of identifiers turns one POST into a bulk export of salary, identity and bank fields, logged as a single successful API call with no record of how many employees it returned. It does depend on an enumerable or harvestable set of identifiers, so where identifiers are random and no listing surface hands them to a low-privilege account, the same gap stays a single-record authorisation finding.
Do persisted queries fix GraphQL security?
Allow-listed persisted operations close more of this off than any other single change, because the GraphQL server will execute only documents registered in advance, so arbitrary aliasing and arbitrary field selection both stop being possible. They bound query shape rather than access: variables stay attacker-controlled, so a registered query that takes a record identifier still needs per-object authorisation behind it. They also fix nothing if the server accepts unregistered documents as a fallback, or if a registered operation accepts an unbounded page size. Note that a build-time allowlist is not the same mechanism as automatic persisted queries, which register client-supplied documents at runtime.
What can an API gateway enforce for GraphQL, and what has to move into the application?
An API gateway in front of GraphQL can cap request body size, reject anything that is not a registered operation identifier, strip HTTP batched operation arrays, and enforce per-identity request budgets. What it cannot decide is whether this caller is entitled to this object, or what a valid query will cost once resolvers start running, because both depend on data the gateway never sees. Those two decisions belong where the resolvers run, which is why a GraphQL-aware router or an in-application check is needed alongside the gateway rather than instead of it.