· 9 min read
Most GraphQL security advice ends where it should begin. Turn off introspection, the guidance says, and the schema is safe. Introspection is a convenience, not a vulnerability. Disabling it hides the map, but the roads are still there, and a client that already talks to the API knows most of them anyway. The interesting failures in a GraphQL service are about authorization, resource consumption and the write path, and none of those are closed by hiding the schema. This is a walk through the classes of issue that survive a disabled introspection endpoint, and how we test them.
Darkmoon carries a dedicated graphql specialist agent among its 50 agents. It reasons about a GraphQL endpoint the way an assessor does, browsing the application through a headless engine to capture the queries the front end actually sends, then probing authorization, batching and mutation behaviour. The point that matters for this article is not a checklist of hard coded techniques. It is that the agent has to prove each issue with a request, a raw response and the data it pulled back, before it is allowed to call anything confirmed.
Test only what you are authorised to test
Everything below is written for engagements against systems you own or have explicit written permission to assess. Batching, alias fan out and mutation probing all generate load and can change state. Run them against your own staging endpoint or an authorised target, never against a third party API you merely have a client for.
Introspection is the map, not the vulnerability
Introspection lets a client query the schema itself: every type, field, argument and mutation, through the __schema and __type meta fields. It is genuinely useful to an attacker because it turns a black box into a documented one in a single request. That is why the standard hardening step is to disable it in production, and it is worth doing.
It is not, on its own, a control. The queries your web and mobile clients ship are visible in network traffic, so the field names, argument shapes and object identifiers are already exposed to anyone running the app. Field suggestion messages in error responses will often reconstruct type names letter by letter even with introspection off. And nothing about hiding the schema changes what the resolvers do when they receive a query. A service that leaks another user's record through a node lookup leaks it whether or not you could have read the schema first. Treat a disabled introspection endpoint as one layer, then test as if the attacker has the schema anyway, because in practice they do.
Broken object level authorization through node IDs
Broken object level authorization, BOLA in the OWASP API Security Top 10 (API1) and often called IDOR, is the single most common serious flaw in GraphQL APIs, and GraphQL has a structural habit that encourages it. Many schemas expose objects by a global node identifier, a node(id: ...) field or a per type user(id: ...) query. The resolver fetches the object by that identifier. If it fetches without checking that the caller is allowed to see that specific object, any authenticated user can read any object by iterating identifiers.
The test is direct. Authenticate as a low privilege user, request an object that belongs to a different user, and read the response. The finding is not that the query is accepted, it is that the data comes back.
# Authenticated as user A (low privilege). Requesting user B's object.
POST /graphql
{"query":"query { user(id: \"1002\") { id email role apiKey } }"}
-> 200 OK
{"data":{"user":{"id":"1002","email":"<redacted, another user>",
"role":"admin","apiKey":"<redacted, in the report>"}}}Two things make this a confirmed BOLA rather than a lead. The identifier belongs to a different principal than the one the token was issued for, and the response contains that principal's private fields. A bare 200 with an empty data object, or a response that only echoes the id back, is not proof of anything and we demote it. The evidence is the extracted email, role and key that the caller was never entitled to. The same test applies through relationship edges: an object you legitimately own may expose a connection to objects you do not, and the authorization check often lives on the top level field but not on the nested one.
Query batching and alias based brute force
Rate limiting on a GraphQL endpoint is usually counted per HTTP request. GraphQL gives an attacker two ways to do a large amount of work inside a single request, which quietly defeats that counting.
The first is aliases. A single query can ask for the same field many times under different names. A login or token check that would be one attempt per request becomes hundreds of attempts in one request, because each alias is resolved independently.
POST /graphql # one HTTP request, one rate-limit "hit"
{"query":"query {
a0: checkCoupon(code: \"AAAA\") { valid }
a1: checkCoupon(code: \"AAAB\") { valid }
a2: checkCoupon(code: \"AAAC\") { valid }
... a999: checkCoupon(code: \"ZZZZ\") { valid }
}"}The second is batching. Many servers accept a JSON array of operations in one request and execute all of them. That is the same multiplier at the operation level rather than the field level, and it applies to mutations too, which is how a per account action limit gets bypassed.
POST /graphql # array body = N operations, one request
[{"query":"mutation { login(user:\"admin\", pass:\"p1\") { token } }"},
{"query":"mutation { login(user:\"admin\", pass:\"p2\") { token } }"},
{"query":"mutation { login(user:\"admin\", pass:\"p3\") { token } }"}]The way to confirm this is to show the multiplier working, not to assert that the endpoint accepts an array. A confirmed finding pairs the batched request with a response that returned a distinct result per alias or per operation, for example one valid: true among the many valid: false, or one operation returning a token. That single differential is the proof that the per request throttle did not bound the actual work performed.
| Technique | What it multiplies | Confirmation signal |
|---|---|---|
| Aliases | Field resolutions per request | Distinct result per alias in one response |
| Array batching | Operations per request | Per operation results, one request |
| Batched mutations | State changing calls per request | N side effects from one HTTP call |
Depth and complexity: turning one query into resource exhaustion
GraphQL lets a client shape the response, and where the schema has cyclic relationships, a client can ask for a shape that costs the server far more than the request cost to send. If a user hasposts, and a post has an author who is a user, a query can descend that cycle to an arbitrary depth. Each level multiplies the resolver work and the database load. This is OWASP API Security API4, unrestricted resource consumption, and it is a denial of service primitive that needs no credentials beyond a valid query.
query {
user(id: "1") {
posts { author {
posts { author {
posts { author { posts { author { id } } } }
} }
} }
}
}Width is the sibling problem. A single flat query that requests many expensive fields, or an aliased query that repeats an expensive resolver, exhausts resources without any depth at all. The honest way to test this is to establish a baseline response time, then increase depth or width incrementally and record where latency climbs, rather than firing the largest possible payload at a production service. The finding is the measured curve, a query at depth N that took materially longer than the same query at depth 1, not the assumption that a deep query would hurt.
Mutations: the write side attackers skip and testers should not
Queries get most of the attention because reading data feels like the whole game. Mutations are where state changes, and they are frequently less carefully guarded than the queries beside them, because teams reason hard about who can read a record and then reuse a thinner check on who can write it. Mutations deserve the same authorization testing as queries, plus a category of their own: mass assignment, where a mutation input accepts a field the caller should not be able to set, such asrole, isAdmin or ownerId.
# Does the update mutation honour a privilege field it should ignore?
mutation {
updateProfile(input: { displayName: "test", role: "admin" }) {
user { id role }
}
}
-> {"data":{"updateProfile":{"user":{"id":"1001","role":"admin"}}}}That response is the finding: the returned object shows the elevated role took effect. A response that silently drops the extra field and returns the old role is the safe outcome, and the difference between the two is exactly what a proof driven test records. Testing mutations against live data is delicate, which is the real reason they get skipped, and it is where an agent has to be careful rather than thorough at any cost.
How the graphql agent proves each issue instead of asserting it
The pattern across every section above is the same. Acceptance of a request proves nothing. A GraphQL server answering 200 to a malformed or over privileged query is normal. What separates a real finding from noise is the response body and the data extracted from it. Darkmoon's agents carry an adversarial status qualification step that enforces exactly this. A finding is only confirmed when it comes with the exact request, the raw response and the extracted data or an execution trace. It is only exploited when the impact was actually carried through end to end. Anything weaker, a bare 200, a differential response, an echoed payload, is demoted to a low severity lead rather than dressed up as a breach. We described the same discipline in our GitLab API audit write up, where all but two of the findings were marked confirmed from API reads rather than claimed as exploitation.
For a GraphQL BOLA, that means the agent authenticates as one user and returns another user's private fields in the transcript. For batching, it shows the per alias differential in a single response. For depth abuse, it records the latency curve. For mass assignment, it shows the elevated field reflected in the returned object. The agent browses the target application first, through a headless engine, to learn the queries the client really uses, so its probes match the schema in play rather than a guessed one. Mutations that change state are approached conservatively, and where a write would be destructive on live data the agent reports the reachable capability rather than executing it, the same boundary we hold on every engagement.
A note on scope honesty. The agent is proof driven, not a fixed catalogue that guarantees coverage of every named GraphQL technique on every target. It reasons about the endpoint in front of it. Treat it as autonomous, evidence backed GraphQL testing, and read the transcript it produces rather than a count of techniques attempted.
What we do not claim
We do not claim the agent exhaustively covers every GraphQL attack for every schema, nor that a clean run means an endpoint is secure. It proves the issues it finds and it browses the real client traffic to guide itself, but coverage depends on what the application exposes and on the authorised scope of the engagement. A finding it does not raise is not evidence of absence.
Remediation
- Enforce object level authorization inside every resolver that fetches by identifier, including nested and connection edges, and never rely on a check at the top level field alone.
- Disable introspection in production, but treat it as defence in depth and disable field suggestion hints in error messages so the schema cannot be reconstructed from responses.
- Cost limit queries with depth and complexity analysis, cap the number of aliases and the batch array size, and rate limit by computed query cost rather than by HTTP request count.
- Reject unknown fields on mutation inputs explicitly rather than ignoring them, and derive privilege fields such as role and owner from the authenticated session, never from client input.
- Apply the same authorization tests to mutations as to queries, and log and alert on batched operations and abnormally deep queries.
FAQ
Is disabling introspection enough to secure GraphQL? No. Introspection is a convenience that speeds up reconnaissance, not the vulnerability itself. Clients already send the queries that reveal field and argument names, and error hints can reconstruct types. Authorization, resource consumption and mutation flaws all persist with introspection off. Disable it, then test as if the attacker has the schema.
How do batching and aliases bypass rate limits? Most rate limits count HTTP requests. Aliases let one request resolve the same field hundreds of times under different names, and array batching lets one request run many operations. Both perform a large amount of work while registering as a single request, so a per request throttle never sees the real volume. Limit by computed cost and cap alias and batch counts instead.
What is BOLA in a GraphQL API? Broken object level authorization, also called IDOR, is when a resolver returns an object by its identifier without checking that the caller is allowed to see that specific object. Because GraphQL often exposes objects by a global node id, a low privilege user can read other users' records by iterating identifiers. It is API1 in the OWASP API Security Top 10.
Can an AI agent test GraphQL mutations without breaking data? Mutations change state, so they need care. The agent tests authorization and mass assignment on mutations, and where an action would be destructive on live data it reports the reachable capability rather than executing it, the same boundary a human assessor holds. Run mutation testing against staging or an authorised target, not a production dataset you cannot restore.
Does the agent send my schema to a cloud model? Darkmoon runs on a local model, and its Privacy Gateway means the model only ever sees deterministic placeholders, never your real IPs, hosts or credentials. Be precise about the limit: as we explain in running a pentest without sending your data to the LLM, deterministic placeholders still leak structure, cardinality and co occurrence by design, and the command gateway is a policy layer rather than a sandbox. Real values are rehydrated locally only at the moment a tool runs and masked back out of every result.
What this proves about autonomous pentesting
GraphQL rewards a tester who reads responses instead of counting status codes, and that is exactly the habit an autonomous agent has to be held to. Darkmoon's value on a GraphQL endpoint is not that it knows the technique names, it is that it browses the real application, sends the probe, and then has to hand back the request, the response and the data before it is allowed to say a word like confirmed. The same evidence discipline runs through our autonomous cloud testing and hardened web application work. That is the difference between a scanner that flags a shape and an assessment that proves an impact.