GraphQL Attack Surface: Introspection Enumeration, Batch Query Abuse, and Depth Limiting
The Problem
GraphQL was designed with a specific philosophy: clients declare exactly what data they need, and the server returns exactly that. The flexibility is real and valuable — but it relocates the attack surface. With REST, an attacker must guess or discover endpoints by brute force. With GraphQL, the server hands them a complete map of every type, field, mutation, and relationship in the system if you leave the defaults in place. Once an attacker has the schema, the attack surface for broken function-level authorization (BFLA), mass assignment, and denial of service is fully enumerated. They do not need to probe.
The attack surface has five distinct components, each exploitable independently:
Introspection makes the schema machine-readable and queryable by design. This is the correct behaviour in development and destructive in production.
Batch queries are a GraphQL specification feature that lets clients send an array of operations in a single HTTP request. Every rate limiter that counts HTTP requests rather than GraphQL operations is blind to this.
Nested queries allow exponential resolver fan-out. A query five levels deep against a schema with one-to-many relationships at each level resolves hundreds of thousands of database calls from one HTTP request.
Aliases allow multiple invocations of the same field with different arguments in one operation. A mutation that rate limits at ten requests per minute can be called a hundred times in one request with aliased names. WAFs inspecting for repeated mutation names miss this entirely.
Field suggestions leak schema information even after introspection is disabled. Most GraphQL implementations respond to invalid field names with “Did you mean X?” — a typo-correction feature that enumerates the real field names one character mutation at a time.
Attack 1: Introspection-Based Schema Enumeration
Every major GraphQL implementation — Apollo, Strawberry, Graphene, Hot Chocolate, gqlgen — enables introspection by default. The __schema and __type meta-fields are part of the GraphQL specification, and the server answers them like any other query. There is no authentication check on introspection by default; the schema is returned to any unauthenticated client.
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ __schema { types { name fields { name type { name kind ofType { name kind } } } } } }"}'
The response is a complete serialised representation of the data model. An attacker reads:
- Every named type in the system, including types never linked from the frontend (internal admin types, draft content types, beta features)
- Every field on every type, including fields gated by authorization logic the client never renders
- Every mutation and its argument signatures, including admin mutations, internal service mutations, and mutations created during development and never removed
- Input types that reveal database column names, enum values that reveal internal state machine states, and scalar names that reveal technology choices
Three tools automate exploitation of this response:
InQL (Burp Suite plugin): consumes an introspection response and generates every possible valid query and mutation for the schema. It produces a complete fuzz corpus from one HTTP request.
graphql-voyager: renders the schema as an interactive entity-relationship graph. An attacker with zero domain knowledge of your product can trace which types connect to which, identify high-value relationships (user → payment, user → adminRole, order → internalStatus), and select mutation targets intelligently.
graphw00f: fingerprints the underlying GraphQL implementation from introspection response characteristics — field ordering, error message format, meta-field behaviour. Knowing the implementation version narrows the search space for known CVEs and configuration weaknesses.
The introspection response is not just documentation. It is a complete attack planning document, generated on demand, requiring no prior knowledge of the target.
Attack 2: Batch Query Rate Limit Bypass
The GraphQL specification allows clients to send an array of operation objects rather than a single object. Each element in the array is a complete, independent operation that the server executes and returns a result for. This is used legitimately to reduce round trips when a client needs multiple independent queries.
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-d '[
{"query": "mutation { login(email: \"u1@example.com\", password: \"Password1!\") { token } }"},
{"query": "mutation { login(email: \"u1@example.com\", password: \"Password2!\") { token } }"},
{"query": "mutation { login(email: \"u1@example.com\", password: \"Password3!\") { token } }"}
]'
Scale this array to one thousand entries, one per password candidate, and you execute a thousand login attempts in a single HTTP request. A rate limiter counting HTTP requests — the overwhelming majority of WAF and API gateway configurations — sees one request, decrements one counter, and allows all one thousand operations to execute.
The server’s response is an array of one thousand result objects, each containing either a token or an authentication error. The attacker iterates the responses to find which password succeeded. The HTTP round trip overhead per attempt is essentially zero.
This is not a theoretical attack. It is the credential-stuffing technique used against GraphQL-based authentication endpoints specifically because they are less likely to have per-operation rate limiting than REST equivalents. The attack is particularly effective against GraphQL APIs that migrated from REST and carried over REST-oriented WAF rules that inspect HTTP request counts rather than operation counts.
Attack 3: Nested Query DoS (O(n^k) Resolver Fan-Out)
GraphQL resolvers are typically implemented independently at each level of the object graph. The root resolver returns a list of objects; each object’s field resolvers run for each item in that list; each nested resolver runs for each item its parent resolver returned. This is correct and expected behaviour. The vulnerability is the absence of a bound on nesting depth or total resolver invocations.
query DeepNested {
users {
orders {
items {
product {
category {
subcategory {
name
}
}
}
}
}
}
}
With realistic data cardinalities — 100 users, 50 orders per user, 20 items per order, a product per item, a category per product, a subcategory per category — the resolver invocation counts are:
| Level | Resolver calls | Cumulative |
|---|---|---|
| users | 100 | 100 |
| orders (per user) | 5,000 | 5,100 |
| items (per order) | 100,000 | 105,100 |
| product (per item) | 100,000 | 205,100 |
| category (per product) | 100,000 | 305,100 |
| subcategory (per category) | 100,000 | 405,100 |
Without DataLoader batching, each resolver invocation is a database query. 405,100 database queries from one HTTP request. With DataLoader batching, this is significantly reduced — but DataLoader is not universal, is frequently misconfigured, and is a mitigation, not a fix. The fundamental problem is that query depth is not bounded at the server.
A depth-limited server rejects the query before any resolver runs:
{"errors": [{"message": "Query depth 6 exceeds maximum allowed depth of 5"}]}
Zero database queries. Zero compute cost beyond query validation.
Attack 4: Aliased Mutation Brute Force
GraphQL aliases allow you to rename the result of any field in the response. They exist to resolve naming conflicts when querying the same field twice with different arguments. Every GraphQL implementation supports them. WAFs that inspect for repeated mutation field names in the query body miss the aliased form entirely.
mutation BruteForce {
a1: login(email: "target@example.com", password: "password1") { token }
a2: login(email: "target@example.com", password: "password2") { token }
a3: login(email: "target@example.com", password: "password3") { token }
a4: login(email: "target@example.com", password: "password4") { token }
a5: login(email: "target@example.com", password: "password5") { token }
}
This is a single GraphQL operation — one mutation {} block — containing five aliased calls to the same resolver with different arguments. An operation-name-based WAF rule blocking repeated login mutations sees this as one BruteForce mutation and passes it. The server executes five login attempts against the same account.
Scale to 100 aliases in one operation and combine with batch query arrays of 100 operations: 10,000 login attempts in one HTTP request, appearing as one request to your rate limiter.
The alias technique applies equally to any resolver, not just authentication. IDOR probing — iterating object IDs to discover accessible records — benefits from aliases because 100 aliased queries for different IDs returns all 100 results in one round trip.
Attack 5: Field Suggestion Information Leakage
Disabling introspection is the correct first step. It is not sufficient. The vast majority of GraphQL implementations include a developer-ergonomics feature: when a client queries a field that does not exist, the error message suggests the closest matching field name.
{ adminUzerz { id } }
{
"errors": [{
"message": "Cannot query field 'adminUzerz' on type 'Query'. Did you mean 'adminUsers'?"
}]
}
With introspection disabled, an attacker cannot query __schema. They can still enumerate the schema by iterating typo variants of guessed field names. Each error response either confirms the guess is wrong (no suggestion) or reveals the correct field name (suggestion present). This is slower than introspection-based enumeration but fully functional and automated by tools like Clairvoyance, which uses a wordlist of common API field names and the suggestion oracle to reconstruct the schema from error messages alone.
Threat Model
The attack chain from unauthenticated access to BFLA exploitation runs in under five minutes on a default GraphQL deployment:
- Send a single introspection query. Receive the complete schema.
- Identify admin mutations (
createUser,deleteAccount,updateRole,generateApiKey) that are not exposed in the frontend. - Send mutations against those admin fields authenticated as a non-admin user.
- If authorization is enforced per-field, check whether input types expose internal fields that can be mass-assigned (e.g., an
isAdminfield on aUserInputtype that the frontend never sends but the resolver accepts).
The DoS chain is simpler: one nested query with no authentication required. If the GraphQL endpoint is publicly accessible without authentication — common for APIs serving public content — the depth bomb requires no credential at all.
The batch rate-limit bypass chain is the most damaging for SaaS products: credential stuffing at effective rates of tens of thousands of attempts per minute, undetected by standard WAF rate limiting, against user authentication endpoints.
Field suggestion enumeration is a fallback when introspection is disabled but suggestions are not: a slower but complete schema reconstruction.
Hardening Configuration
1. Disable Introspection in Production
Apollo Server (Node.js):
const { ApolloServer } = require('@apollo/server');
const server = new ApolloServer({
schema,
introspection: process.env.NODE_ENV !== 'production',
});
When introspection is false, Apollo refuses to execute any query that accesses __schema or __type. The response is a validation error, not a data response:
{
"errors": [{
"message": "GraphQL introspection is not allowed, but the query contained __schema or __type.",
"extensions": { "code": "GRAPHQL_VALIDATION_FAILED" }
}]
}
Python with Strawberry:
import strawberry
from strawberry.extensions import DisableIntrospection
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
extensions=[DisableIntrospection()],
)
If you need introspection for internal tooling (GraphQL Playground, Insomnia, code generation), do not enable it globally in production. Instead, gate it on an internal network check or a specific header checked before the introspection request reaches the GraphQL engine:
const server = new ApolloServer({
schema,
introspection: false,
plugins: [
{
async requestDidStart({ request }) {
const isInternal = request.http?.headers.get('x-internal-introspection') === process.env.INTROSPECTION_SECRET;
if (!isInternal && isIntrospectionQuery(request.query)) {
throw new GraphQLError('Introspection disabled', {
extensions: { code: 'INTROSPECTION_DISABLED' },
});
}
},
},
],
});
2. Disable Field Suggestions
Introspection disabled with field suggestions active is a partial mitigation. Remove suggestions from all error responses.
Apollo Server (custom error formatter):
const server = new ApolloServer({
schema,
introspection: false,
formatError(formattedError, error) {
// Strip "Did you mean X?" suggestions from field-not-found errors
const message = formattedError.message.replace(
/ Did you mean (".+"|\[".+"\])\?/,
''
);
return { ...formattedError, message };
},
});
Python (Strawberry custom error handler):
import re
from graphql import GraphQLError
def format_error(error: GraphQLError) -> dict:
formatted = error.formatted
if 'message' in formatted:
formatted['message'] = re.sub(
r' Did you mean .+?\?',
'',
formatted['message'],
)
return formatted
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
extensions=[DisableIntrospection()],
)
# Apply in your ASGI/WSGI handler:
# graphql_response = await schema.execute_async(
# query, ...
# )
# errors = [format_error(e) for e in graphql_response.errors or []]
After this change, a query for a non-existent field returns:
{
"errors": [{
"message": "Cannot query field 'adminUzerz' on type 'Query'."
}]
}
No suggestion. Clairvoyance-style enumeration returns the same error for every typo variant — no oracle.
3. Query Depth and Complexity Limiting
Depth limiting is a prerequisite, not a complete solution. A query that is only three levels deep but selects 50 fields on a type with 100,000 rows still causes substantial load. Implement both.
Depth limiting with graphql-depth-limit (Node.js):
const { ApolloServer } = require('@apollo/server');
const depthLimit = require('graphql-depth-limit');
const server = new ApolloServer({
schema,
validationRules: [depthLimit(5)],
});
A query exceeding depth 5 is rejected at validation time, before any resolver executes:
{
"errors": [{
"message": "'DeepNested' exceeds maximum operation depth of 5"
}]
}
Complexity limiting with graphql-query-complexity (Node.js):
const { ApolloServer } = require('@apollo/server');
const {
createComplexityLimitRule,
simpleEstimator,
fieldExtensionsEstimator,
} = require('graphql-query-complexity');
const server = new ApolloServer({
schema,
validationRules: [
createComplexityLimitRule(1000, {
estimators: [
// Assign complexity costs per field; list fields cost more
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
onCost: (cost) => {
console.log('Query complexity:', cost);
},
}),
],
});
Annotate list-returning fields in your schema with their complexity weight:
// Schema definition with complexity extensions
const typeDefs = gql`
type Query {
users: [User!]! @complexity(value: 10, multipliers: ["limit"])
user(id: ID!): User @complexity(value: 1)
}
`;
Python with Strawberry using strawberry-django-query-counter or a custom validation rule:
from graphql import ValidationRule, GraphQLError
from graphql.language import FieldNode
class MaxDepthRule(ValidationRule):
def __init__(self, context, max_depth: int = 5):
super().__init__(context)
self.max_depth = max_depth
self._current_depth = 0
def enter_field(self, node: FieldNode, *_args):
# Skip introspection fields (__schema, __type)
if node.name.value.startswith('__'):
return
self._current_depth += 1
if self._current_depth > self.max_depth:
self.report_error(GraphQLError(
f"Query depth {self._current_depth} exceeds maximum allowed depth of {self.max_depth}"
))
def leave_field(self, node: FieldNode, *_args):
if not node.name.value.startswith('__'):
self._current_depth -= 1
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
extensions=[DisableIntrospection()],
validation_rules=[lambda ctx: MaxDepthRule(ctx, max_depth=5)],
)
4. Reject or Bound Batch Queries
Express middleware to block array-body batch requests entirely:
app.use('/graphql', (req, res, next) => {
if (Array.isArray(req.body)) {
return res.status(400).json({
errors: [{ message: 'Batch queries are not supported' }],
});
}
next();
});
If your application legitimately needs batching (Apollo Client’s BatchHttpLink for example), enforce a hard cap on batch size and rate-limit per-operation rather than per-request:
app.use('/graphql', (req, res, next) => {
if (Array.isArray(req.body)) {
const MAX_BATCH = 10;
if (req.body.length > MAX_BATCH) {
return res.status(400).json({
errors: [{
message: `Batch size ${req.body.length} exceeds maximum of ${MAX_BATCH}`,
}],
});
}
// Rate limit against the total operation count, not the request count
const operationCount = req.body.length;
const allowed = rateLimit.consume(req.ip, operationCount);
if (!allowed) {
return res.status(429).json({
errors: [{ message: 'Rate limit exceeded' }],
});
}
}
next();
});
The rate limiter must decrement by operationCount, not by 1. Any rate limiter that does not receive the operation count from the GraphQL layer is blind to batch amplification.
5. Alias-Aware Per-Resolver Rate Limiting
Standard middleware-level rate limiting does not count aliased resolver invocations. Implement rate limiting at the resolver level using a field middleware or plugin that counts each resolver call against the client’s quota, regardless of aliasing.
Apollo Server resolver middleware:
const { MapperKind, mapSchema } = require('@graphql-tools/utils');
const { defaultFieldResolver } = require('graphql');
function resolverRateLimitPlugin(rateLimits) {
return {
schema: mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => {
const limit = rateLimits[`${typeName}.${fieldName}`];
if (!limit) return fieldConfig;
const { resolve = defaultFieldResolver } = fieldConfig;
return {
...fieldConfig,
async resolve(source, args, context, info) {
const key = `${context.clientIp}:${typeName}.${fieldName}`;
const consumed = await rateStore.increment(key);
if (consumed > limit.perMinute) {
throw new GraphQLError(
`Rate limit exceeded for ${fieldName}`,
{ extensions: { code: 'RATE_LIMITED' } }
);
}
return resolve(source, args, context, info);
},
};
},
}),
};
}
// Configuration: 10 login resolver calls per IP per minute
// Counts each alias as one call
const schema = resolverRateLimitPlugin({
'Mutation.login': { perMinute: 10 },
'Mutation.resetPassword': { perMinute: 5 },
});
A mutation with 100 aliases for login will trigger the rate limit after the 10th aliased call within the same request, return a rate limit error for the remaining 90, and decrement the client’s budget appropriately.
6. Persisted Queries (Production-Grade Hardening)
Persisted queries are the strongest mitigation for arbitrary query abuse. The server maintains a registry of pre-approved query hashes. Any operation not in the registry is rejected before execution. Arbitrary queries — introspection, depth bombs, aliased brute force — are structurally impossible to execute.
// Build-time: generate hashes for all legitimate queries
// queries/login.graphql → sha256:abc123...
// queries/getUser.graphql → sha256:def456...
const QUERY_REGISTRY = new Map(
Object.entries(require('./generated/persisted-queries.json'))
);
// { "sha256:abc123": "mutation Login($email: String!, $password: String!) { ... }" }
app.use('/graphql', async (req, res, next) => {
const { extensions, query } = req.body ?? {};
const hashKey = extensions?.persistedQuery?.sha256Hash;
if (!hashKey) {
// No hash provided — arbitrary query rejected in production
if (process.env.NODE_ENV === 'production') {
return res.status(400).json({
errors: [{ message: 'Persisted query hash required' }],
});
}
// Development: allow arbitrary queries
return next();
}
const registeredQuery = QUERY_REGISTRY.get(`sha256:${hashKey}`);
if (!registeredQuery) {
return res.status(400).json({
errors: [{ message: 'Unrecognised query hash' }],
});
}
// Replace the request body's query with the server-side registered copy
req.body.query = registeredQuery;
next();
});
The client sends only the hash and variables:
{
"extensions": {
"persistedQuery": { "version": 1, "sha256Hash": "abc123..." }
},
"variables": { "email": "user@example.com", "password": "hunter2" }
}
The server substitutes the registered query text. An attacker cannot inject custom query bodies because the server ignores the query field in favour of the registry lookup. An introspection query has no registered hash. A depth-bomb query has no registered hash. An aliased brute-force mutation has no registered hash. They all return Unrecognised query hash.
7. Gateway-Level Nginx Rate Limiting by Operation Type
WAF rules that rate limit per IP without inspecting the GraphQL body treat mutations and queries identically. Nginx with Lua (OpenResty) can parse the request body and apply differentiated rate limits:
http {
lua_shared_dict gql_limits 10m;
server {
location /graphql {
access_by_lua_block {
local cjson = require "cjson"
local body = ngx.req.get_body_data()
if not body then
ngx.req.read_body()
body = ngx.req.get_body_data()
end
if body then
local ok, parsed = pcall(cjson.decode, body)
if ok and type(parsed) == "table" then
-- Reject batch queries at the gateway
if parsed[1] ~= nil then
ngx.status = 400
ngx.header["Content-Type"] = "application/json"
ngx.say('{"errors":[{"message":"Batch queries not supported"}]}')
return ngx.exit(400)
end
-- Differentiated rate limiting: mutations get 10/min, queries 100/min
local query = parsed.query or ""
local is_mutation = query:match("^%s*mutation")
local limit_key = is_mutation
and ("gql_mut:" .. ngx.var.remote_addr)
or ("gql_qry:" .. ngx.var.remote_addr)
local limit = is_mutation and 10 or 100
local window = 60 -- seconds
local dict = ngx.shared.gql_limits
local count, err = dict:incr(limit_key, 1, 0, window)
if count and count > limit then
ngx.status = 429
ngx.header["Content-Type"] = "application/json"
ngx.header["Retry-After"] = "60"
ngx.say('{"errors":[{"message":"Rate limit exceeded"}]}')
return ngx.exit(429)
end
end
end
}
proxy_pass http://graphql_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}
This rejects batch arrays before they reach the backend, applies a 10/minute rate limit for mutation bodies and 100/minute for query bodies, and uses a sliding-window counter in shared memory. The limitation is that it only inspects the top-level query string for the mutation keyword — aliased mutations inside a query {} block would pass as a query. Resolver-level rate limiting (section 5) is necessary to catch that case.
Expected Behaviour After Hardening
Introspection query ({ __schema { types { name } } }):
{
"errors": [{
"message": "GraphQL introspection is not allowed, but the query contained __schema or __type.",
"extensions": { "code": "GRAPHQL_VALIDATION_FAILED" }
}]
}
Depth-exceeded query (depth 6 against a limit of 5):
{
"errors": [{
"message": "'DeepNested' exceeds maximum operation depth of 5",
"locations": [{ "line": 1, "column": 1 }]
}]
}
Batch query (array body):
{
"errors": [{ "message": "Batch queries are not supported" }]
}
HTTP 400. The array never reaches the GraphQL engine.
Unregistered query hash in persisted-query mode:
{
"errors": [{ "message": "Unrecognised query hash" }]
}
HTTP 400. The query text is never parsed or executed.
Aliased resolver rate limit (11th login alias in one minute):
{
"data": {
"a1": { "token": null },
"a2": { "token": null },
...
"a10": { "token": null },
"a11": null
},
"errors": [{
"message": "Rate limit exceeded for login",
"path": ["a11"],
"extensions": { "code": "RATE_LIMITED" }
}]
}
The first 10 aliased calls execute; the 11th and subsequent aliases fail with a rate limit error within the same response document.
Trade-offs
Disabling introspection breaks GraphQL Playground, Apollo Studio Explorer, Insomnia’s schema import, and any code generator that fetches the schema at runtime. The correct fix is not to re-enable introspection globally but to maintain a committed schema file (schema.graphql) in version control and generate client types from that file at build time. Internal tooling that needs introspection should authenticate using a separate internal token checked before introspection is allowed.
Persisted queries require build-time coordination between frontend and backend. Every new query the frontend uses must be registered before deployment. Dynamic query construction — where a client assembles queries based on user input — is incompatible with persisted queries by design. This is a feature, not a bug: dynamic query construction is the mechanism that enables injection and DoS queries. If your application requires dynamic queries, implement complexity and depth limiting as the primary control instead.
Depth limiting set too conservatively breaks legitimate queries. Some schemas have genuinely deep object graphs — audit your legitimate query depths before setting the limit. A limit of 5 is appropriate for most APIs; schemas with deep nesting for legitimate reasons may need 7–8. Set the limit by examining actual client queries in logs, not by guessing. Complexity limiting is a better long-term control because it can assign different weights to different field types.
Resolver-level rate limiting adds latency to every resolver call because it performs a cache/store increment per invocation. Use an in-memory counter (Redis with pipelining, or a local in-process store with periodic sync) rather than a database write per resolver call. Benchmark the overhead against your p99 latency budget before deploying.
Nginx/Lua batch detection using body inspection requires lua_need_request_body on or ngx.req.read_body(), which buffers the entire request body in memory before passing it to the backend. For very large request bodies this increases memory pressure. Set a client_max_body_size appropriate to your maximum legitimate request size (typically 1MB for GraphQL APIs) to bound this.
Failure Modes
Disabling introspection without disabling field suggestions: schema enumeration via Clairvoyance remains fully functional. A wordlist of 10,000 common API field names produces a nearly complete schema reconstruction in under an hour. Both controls must be applied together.
Depth limiting without complexity limiting: a query three levels deep that selects 100 fields on a type returning 10,000 rows can generate 10,000 × 100 = 1,000,000 field resolutions without exceeding the depth limit. Depth is not a proxy for load; complexity is. Deploy both rules.
Rate limiting per HTTP request at the gateway without per-operation counting in the application layer: batch queries bypass the gateway limit entirely. The gateway sees one request; the backend executes one thousand operations. Application-layer per-operation counting is the only reliable control against batch amplification.
Persisted queries in development, arbitrary queries in production: if development environments allow arbitrary queries and share authentication infrastructure, credentials, or internal tooling with production, attackers can map the schema in development and exploit it in production. The persisted query enforcement must be present in every environment that shares attack surface with production, including staging.
Resolver-level rate limiting that resets counters per request rather than per time window: a rate limiter that allows 10 login calls “per request” rather than “per minute per IP” is bypassed by sending many small requests. Counters must be externalized (Redis, Memcached) and expire on a time window relative to the first call, not the request boundary.
WAF rules inspecting mutation operation name but not aliased field names: a mutation BruteForce { a1: login(...) a2: login(...) } passes a WAF rule blocking operations named login. The mutation name is controlled by the attacker. Operation-name WAF rules are security theatre for GraphQL. Control must be at the resolver level.