Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
GraphQL replaces the many-endpoints model of REST with a single endpoint (typically /graphql) and a client-specified query that describes exactly which fields and nested objects it wants back. That flexibility is the appeal, and also the risk: the entire data model, every query, mutation, and often internal/administrative fields, lives behind one URL, and traditional per-route controls — a WAF rule keyed on a path, a rate limiter keyed on an endpoint, an ACL keyed on a REST resource — don’t map cleanly onto a schema where the client chooses the shape of the request.
Three risk classes dominate GraphQL security assessments. Introspection lets a client ask the API to describe itself — every type, field, argument, and mutation — turning a black-box target into a fully documented one before an attacker sends a single exploit request. Batching and aliasing let a client pack many logically distinct operations into one HTTP request, which defeats naive per-request rate limiting and turns enumeration or credential stuffing into a single, quiet round trip. And broken object/field-level authorization — GraphQL’s version of IDOR — is endemic because the authorization check that REST puts in one middleware per route has to be re-implemented inside every resolver in a GraphQL schema, and it routinely isn’t.
Attack Prerequisites
Exploiting a GraphQL API generally requires:
- A reachable GraphQL endpoint (commonly
/graphql,/api/graphql,/v1/graphql, or a Hasura/Apollo/PostGraphile default path) accepting POST with a JSON{"query": ...}body, sometimes also GET. - Introspection enabled in production — the default in many frameworks unless explicitly disabled (Apollo Server, graphql-yoga, Hasura’s console) — or a schema that can be reconstructed indirectly through field-suggestion error messages even when introspection is nominally off.
- Resolvers that fetch or mutate objects by an ID supplied in the query arguments without verifying the caller owns or is authorized to see that specific object (broken object-level authorization).
- No effective per-operation cost, depth, or rate limiting, so a single HTTP request can carry dozens or hundreds of aliased/batched operations without tripping request-count-based defenses.
How It Works
Introspection is part of the GraphQL specification itself — the __schema and __type meta-fields exist so tools like GraphiQL and Apollo Studio can auto-complete queries against a live server. If left enabled outside development, that same mechanism hands an attacker a complete, authoritative map of the API: every type, every argument, every mutation, including ones never referenced by the public frontend — the equivalent of leaking a REST API’s OpenAPI spec, undocumented admin routes included, without any documentation ever being published.
Batching compounds the problem. Aliasing — giving the same field a different name so it can appear multiple times in one query document, part of the base spec — lets a client request user(id:1) and user(id:2) and user(id:3) as a, b, c inside a single query. Many server implementations also accept a JSON array of independent operations in one POST body. Both let an attacker issue hundreds of effectively independent operations — login attempts, per-ID lookups — inside one HTTP request. Because most rate limiters and WAFs count HTTP requests rather than GraphQL operations, this is an effective bypass for brute-force protection and a fast path to mass enumeration; combined with deeply nested or circular queries it is also a denial-of-service vector (query depth/complexity attacks).
Broken object-level authorization happens because GraphQL collapses what would be dozens of REST routes into fields of one schema, so the ownership check that REST enforces once, in a routing middleware, has to be reimplemented inside every resolver that touches a per-user object. It is extremely common for a resolver such as Query.invoice(id) or Mutation.updateInvoiceStatus(id) to fetch or mutate directly by primary key with no check tied to the caller’s session, because the developer conflated *authentication* (you have a valid session) with *authorization* (you are allowed to touch this specific object).
Vulnerable Code / Configuration
Introspection left on in a production server config — the schema is fully enumerable by anyone who can reach the endpoint:
const server = new ApolloServer({
typeDefs,
resolvers,
// VULNERABLE: introspection defaults to true and is never disabled
// for the production environment.
introspection: true,
});
JavaScriptA resolver with no per-object authorization check — any authenticated (or in some configurations, unauthenticated) caller can read or modify any invoice by simply changing the id argument:
const resolvers = {
Query: {
// VULNERABLE: no ownership check against ctx.user
invoice: async (_, { id }, ctx) => db.invoices.findById(id),
},
Mutation: {
// VULNERABLE: same bug, now a write primitive
updateInvoiceStatus: async (_, { id, status }, ctx) =>
db.invoices.update(id, { status }),
},
};
JavaScriptNo query cost limiting, depth limiting, or batch cap on the server — nothing bounds how many aliased operations or how much nesting one request can contain:
// VULNERABLE: no validationRules, no depth/cost limiter, batching
// left at framework defaults.
const server = new ApolloServer({ typeDefs, resolvers });
app.use('/graphql', express.json(), expressMiddleware(server));
JavaScriptWalkthrough / Exploitation
First confirm the endpoint and pull the full schema via the standard introspection query:
curl -s https://target.example/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{__schema{queryType{name} mutationType{name} types{name kind fields{name args{name type{name}}}}}}"}' \
| python -m json.tool > schema.json
BashReview schema.json for interesting mutations and query names (login, resetPassword, user, invoice, admin*). Tools such as InQL (Burp extension), GraphQL Voyager, or graphql-cop automate this recon and generate a query catalogue from the dumped schema.
Abuse aliasing to brute-force a login mutation in a single HTTP request — each alias is a distinct attempt but only one request hits the rate limiter:
mutation {
a: login(username: "admin", password: "password1") { token }
b: login(username: "admin", password: "password2") { token }
c: login(username: "admin", password: "Summer2024!") { token }
d: login(username: "admin", password: "admin123") { token }
}
GRAPHQLBatch an array of operations to enumerate objects by ID — again, one HTTP request, dozens of results:
[
{"query": "{ user(id: 1) { id email isAdmin } }"},
{"query": "{ user(id: 2) { id email isAdmin } }"},
{"query": "{ user(id: 3) { id email isAdmin } }"}
]
JSONExploit the missing object-level check directly to read or mutate another tenant’s data:
mutation {
updateInvoiceStatus(id: 4321, status: "PAID") { id status total }
}
# id 4321 was never issued to this account — the mutation succeeds anyway.
GRAPHQLNote: Field-suggestion errors can leak the schema even when introspection is disabled: many GraphQL servers respond to a slightly misspelled field with “Did you mean ‘usernme’?”, and tools like clairvoyance brute-force an entire schema back out of a production endpoint purely from those suggestions, field by field.
Opsec: Aliased and batched attacks are far quieter than the equivalent set of parallel HTTP requests — one TCP connection, one access-log line, often one entry in a WAF’s request counter — which is exactly why they defeat request-based brute-force and rate-limit defenses that were designed with REST traffic patterns in mind.
Detection and Defense
Controls need to live inside the GraphQL layer itself, not just at the HTTP edge:
- Disable introspection in production (
introspection: falsein Apollo Server, or gate it behind an authenticated/dev-only environment check) — not a real security boundary, but it removes trivial recon. - Enforce query depth and cost limits (
graphql-depth-limit, cost-analysis middleware) so a single request cannot nest or batch its way past resolver cost budgets. - Cap or disable batched/aliased operations, or rate-limit per resolved operation rather than per HTTP request.
- Authorize per object and per field inside resolvers, ideally via a reusable directive (
@auth,@hasRole) rather than ad hoc checks copied into each resolver. - Use persisted queries / an allowlist of approved query documents in production so arbitrary client-crafted queries are rejected outright.
- Log and alert on GraphQL-specific signals:
__schemaqueries in access logs, unusually high alias counts per operation, and repeated distinctidarguments against the same field from one session.
Real-World Impact
Broken object-level authorization in GraphQL APIs is explicitly called out in the OWASP API Security Top 10 (API1: Broken Object Level Authorization) as a category GraphQL’s flexible field selection makes easy to introduce and easy to miss in code review. Public bug bounty disclosures against large-scale GraphQL deployments (companies running GitHub- and Shopify-style GraphQL APIs) repeatedly show the same two findings: introspection left enabled on production endpoints, and IDOR reachable through a query or mutation argument that was never checked against the caller’s session — both are consistently rated high severity because they expose or mutate other users’ data directly.
Conclusion
GraphQL doesn’t introduce new vulnerability classes so much as it concentrates old ones — enumeration, brute force, and IDOR — behind a single endpoint where the client controls the shape of every request. The fix is equally concentrated: turn off introspection outside development, bound query cost and batching, and treat every resolver that touches a specific object as its own authorization boundary rather than trusting that a valid session is enough.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments