GraphQL has no authorisation layer: aliases, batching, and the resolvers everyone forgets
GraphQL moves authorisation from the route to the resolver, and most teams only notice half of them. Introspection, alias multiplication, batched mutations against rate limits, and the complexity-DoS CVEs, with the fixes that actually hold.
8 min read3 views
REST gives you a natural place to put an authorisation check: the route. One URL, one handler, one middleware. It is not a great place, as the BOLA record shows, people still forget, but it is at least an obvious one.
GraphQL removes it. There is one endpoint, usually POST /graphql, and a query that traverses a graph. A single request can reach dozens of resolvers across many types. Every one of those resolvers is an authorisation boundary, and there is no framework-level default that enforces anything.
That is not a flaw in GraphQL. It is a consequence of the design: the client composes the traversal, so the server cannot know in advance which objects a request will touch. But it means a team migrating from REST inherits an authorisation model with roughly ten times as many places to get it wrong, and no obvious place to look.
Start with introspection
GraphQL ships with a query that returns the entire schema. Every type, field, argument, mutation and deprecated leftover.
{ __schema { types { name fields { name args { name type { name } } } } } }
In production, that is a free map. It shortens reconnaissance from days to a single request and surfaces exactly the things you would rather nobody found: adminUser, internalNotes, impersonate, resetPasswordToken, the v1 type left behind by the migration.
The obvious response is to disable introspection, and you should. But do not record it as a fix, for two reasons.
First, it is not access control. It is discovery reduction. The fields still exist and still resolve; you have removed the index, not the book.
Second, disabling it is less effective than people assume. Many servers keep field-suggestion errors enabled: ask for usr and the response says "Did you mean user?". Tooling such as Clairvoyance reconstructs a usable schema from those suggestions by brute force. If you turn introspection off, turn off field suggestions too (didYouMean, or the equivalent validation rule for your server), otherwise you have added an inconvenience and logged a false sense of progress.
Also check the usual places a schema leaks anyway: the front-end bundle (Apollo and Relay often embed the operations), .graphql files served as static assets, persisted-query manifests, and the schema registry if it is exposed.
Aliases: the same field, N times
This is the GraphQL-specific technique worth knowing, because it defeats controls that look adequate.
Aliases let a client request the same field repeatedly under different names in one operation:
mutation {
a1: login(user: "admin", code: "0001") { token }
a2: login(user: "admin", code: "0002") { token }
a3: login(user: "admin", code: "0003") { token }
# ... and so on
}
Your rate limiter counts HTTP requests. It sees one. Your brute-force protection counts login attempts per request. It sees one. The resolver, however, runs once per alias.
That turns a "5 attempts per minute" policy into 500 attempts per minute with 100 aliases per request, and it applies to anything with a per-request limit: one-time password validation, coupon redemption, password reset, email verification codes, invite acceptance. Any short numeric code that is protected by attempt limiting rather than by lockout is exposed by this.
Array batching is the same idea at the transport layer. Many servers accept a JSON array of operations in one HTTP body:
[{"query": "..."}, {"query": "..."}, {"query": "..."}]
Same effect, and it also bypasses per-operation cost analysis on servers that apply the limit per operation rather than per request.
HackerOne paid $12,500 on its own GraphQL API for the other half of this primitive. The verifyAccountRecoveryPhoneNumber mutation could be aliased many times in a single request, and each aliased execution took around eight seconds, so one request was enough to exhaust the resources behind the endpoint. That was a denial of service finding, not a rate-limit bypass, and the distinction matters less than the thing both share: one HTTP request, many resolver executions. Whether that surfaces as resource exhaustion or as five hundred login attempts depends only on what the resolver happens to do. A company whose business is finding bugs in other people's software had it in its own account recovery flow.
The general lesson: any control that counts HTTP requests is measuring the wrong unit in GraphQL. Move it to resolver invocations.
Query complexity, and the CVEs that came from it
A client can ask for deeply nested or heavily multiplied data:
{ users { posts { comments { author { posts { comments { id } } } } } } }
Each level multiplies the work. Without a bound, a small request becomes a large database load. A denial of service where the request itself looks innocuous.
This has produced a genuine run of CVEs across implementations rather than in any one product. Examples reported across the ecosystem include graphql-java (CVE-2023-28867), graphql-js (CVE-2023-26144), gqlparser (CVE-2023-49559), async-graphql (CVE-2024-47614), Directus (CVE-2024-39895, where load scaled linearly with alias count), and Apollo Router (CVE-2025-32032).
That spread is the point. A vulnerability class that recurs across every independent implementation of a specification is telling you something about the specification's defaults, not about any one team's competence. If you run GraphQL, complexity limiting is not an optimisation you get to defer.
The authorisation problem underneath
Introspection and aliasing are GraphQL-flavoured. The bug that actually loses data is the one from the REST world, relocated.
{ order(id: "9182") { total customer { email address } } }
Two authorisation decisions. Does the caller own order 9182? And may the caller read the customer object hanging off it? Teams check the first and inherit the second, because the customer resolver "is only reachable through an order the user owns", until a new query path reaches it another way.
The specific failures to look for:
- Nested traversal to objects you cannot query directly.
user(id: X)may be locked down whilepost(id: Y) { author { email } }returns the same data through a side door. - Mutations that are less protected than queries. They tend to be written later and reviewed less.
- Node interfaces. A global
node(id:)field is a single entry point to every object in the graph. It is exactly the BOLA shape and it deserves a dedicated review. - Field-level exposure. A type carrying
passwordHash,resetToken,internalScoreorstripeCustomerIdwill eventually be reachable from some query path. If it does not need to be in the schema, remove it from the schema. - Union and interface types, where an implementing type has fewer checks than its siblings.
Testing it
- Get the schema. Introspection, or the front-end bundle, or Clairvoyance against suggestion errors.
- Map every path to every sensitive type. This is the step people skip and it is the one that finds bugs, for each type containing data that matters, enumerate every query and traversal that reaches it, and test authorisation on each path independently.
- Two accounts, as always. Fetch B's objects with A's token, through every path found in step 2.
- Alias-multiply anything rate limited. 100 aliases in one operation against login, OTP, coupon, reset.
- Try array batching, and try it specifically against controls you found were enforced per-operation.
- Depth and breadth probes. Nest a recursive relationship and watch response time. Do this carefully and with permission. It is a load test.
- Check verbs and transports. Some servers accept queries over
GET(which opens CSRF and cache poisoning), and mutations overGETis a finding on its own.
Fixing it
Authorise in the data layer, not the resolver. The same conclusion as REST, and it matters more here because there are more resolvers. If loading an object requires the actor, through a scoped repository or a data loader that takes the viewer as an argument, then a forgotten resolver check is not exploitable.
Add a schema-level authorisation directive (@auth(requires: ...)) so the requirement is declared next to the field and is visible in review. Better still, make the build fail on any field with no directive: an explicit @public is safer than a silent default, because the failure mode becomes "the build breaks" rather than "the field is open".
Bound the query. Maximum depth, maximum complexity/cost, and maximum aliases per operation. Reject over budget rather than degrading. Persisted queries (an allowlist of operation hashes the client may send) are the strongest form: unknown operations are refused outright, which removes complexity attacks, alias abuse and most reconnaissance at once. If your clients are all first-party, there is little reason not to.
Count resolver calls, not HTTP requests, for rate limiting.
Disable introspection and field suggestions in production.
Detection
- Operations with an unusually high alias count, or the same field name repeated many times in one document. Log alias counts.
__schemaor__typein a production query body, legitimate clients do not introspect in production.- Batched arrays over a small threshold.
- Queries exceeding your depth or complexity budget: log every rejection, since rejections are attempts.
- Sustained field-suggestion errors from one client. That is Clairvoyance, and it is loud if you look.
Take this away
REST gives you one authorisation checkpoint per route and people forget some of them. GraphQL gives you one per resolver, hands the client the routing table, and lets a single request visit as many as it likes.
The only version of this that scales is to stop treating authorisation as something a resolver does, and make it something the data layer cannot skip.
Further reading
- OWASP GraphQL Cheat Sheet
- PortSwigger: GraphQL API vulnerabilities
- Clairvoyance, schema recovery with introspection disabled
Was this useful?
Comments
Loading comments…