cd ../blog

GraphQL Abuse: Introspection, Batching, and Deep Queries

GraphQL's flexibility is also its attack surface. We cover introspection leakage, batching-based brute force, and denial of service via deeply nested queries.

GraphQL hands clients enormous query flexibility, and that same flexibility is the attack surface: a single endpoint, an introspectable schema, and the ability to batch and nest queries each open a distinct hole. This is how to map a GraphQL API and prove the abuse.

Finding it

Locate the endpoint first — /graphql, /api/graphql, /v1/graphql, or a query/POST to /. Confirm it is GraphQL by sending {"query":"{__typename}"} and looking for a {"data":{"__typename":"Query"}} response.

What to probe:

  • Introspection enabled? If so, you get a complete map of every type, field, and mutation — including the ones the UI never calls.
  • Field suggestions in errors? Even with introspection off, a typo'd field name often returns "Did you mean ...", leaking the schema.
  • Batching/aliasing allowed? Lets you defeat per-request rate limits.
  • Per-field authorization? A guarded root with an unguarded nested edge is the classic IDOR.

Tooling: graphw00f fingerprints the engine, and a query in clairvoyance reconstructs the schema from field-suggestion errors even when introspection is disabled.

Proof of concept

Introspection dump

The full schema in one request:

query {
  __schema {
    types { name fields { name } }
    mutationType { fields { name args { name } } }
  }
}

The mutation list is the prize — it reveals hidden administrative mutations and deprecated fields that often have weaker authorization than anything reachable from the UI.

Batching brute force

Aliasing lets you pack hundreds of attempts into one HTTP request, sailing past rate limits that count requests:

mutation {
  a: login(user:"victim", pass:"0000") { token }
  b: login(user:"victim", pass:"0001") { token }
  c: login(user:"victim", pass:"0002") { token }
}

If one alias returns a token, you cracked a credential in a single request that a per-request limiter saw as one attempt. The same trick brute-forces OTPs and coupon codes.

Nested-field authorization bypass

Find an authorized root (me) with a nested edge that performs no ownership check:

query {
  me {
    paymentMethods { id last4 brand }   # does this edge re-check ownership?
  }
}

Then try walking from a permitted root into data that was never meant to be returned — e.g. me { organization { members { email } } }. If it resolves members you should not see, that is BOLA at the field level.

Going further

Mutation mass assignment is the write-side equivalent. If an input type accepts a field the client should not control, set it directly:

mutation {
  updateUser(input: { id: "123", role: "ADMIN", isVerified: true }) {
    id role
  }
}

A resolver that blindly persists role/isVerified hands you privilege escalation through the variables alone.

For a denial-of-service proof, exploit a cyclic relationship (user -> posts -> author -> posts ...) with a deeply nested query whose resolution cost explodes:

query {
  user(id:1){ posts { author { posts { author { posts { id }}}}}}
}

Measure the response time climbing as you add levels — a clear cost-blowup demonstration without taking the service down.

The schema is a contract, and every field in it is reachable unless explicitly stopped, so the highest-yield finding is usually a sensitive field or admin mutation the UI simply never called. Capture the query, the response, and — for batching — proof that the rate limiter counted it as one request. Keep all testing on authorized targets and use accounts you control.