GraphQL Introspection and Batching Attacks: Locking Down Your API
You added GraphQL to your stack because it lets clients fetch exactly what they need. The problem is that same flexibility also lets attackers ask your API exactly what they need to attack it β your full schema, every type, every resolver, every field. Before you ship to production, there are two specific attack classes you need to close off: introspection-based reconnaissance and batching-based abuse.
Why GraphQL Changes the Attack Surface
Traditional REST APIs scatter endpoints across a surface area an attacker must map one URL at a time. GraphQL collapses that into a single endpoint β usually /graphql β that speaks a structured query language. That single endpoint carries enormous power, and with default configurations it is almost entirely self-describing.
Two features cause the most damage in the wrong hands: introspection, which exposes your full schema on request, and query batching, which lets a single HTTP request carry multiple independent operations. Neither is a bug in GraphQL itself β both are legitimate features that need deliberate hardening before they face the internet.
What You'll Learn
- How introspection works and what an attacker can extract from it.
- How to safely disable or restrict introspection without breaking developer tooling.
- What batching and alias-based attacks look like and how they cause resource exhaustion.
- Concrete defenses: depth limits, complexity analysis, and per-operation rate limiting.
- Common misconfigurations teams make when they think they've already fixed these issues.
Understanding GraphQL Introspection
Introspection is a built-in GraphQL capability that lets any client query the schema itself. You've used it every time you ran a tool like GraphiQL or Insomnia and watched autocomplete suggest field names. The spec defines a set of meta-fields β __schema, __type, __typename β that return structured descriptions of every type, field, argument, and directive your API exposes.
A canonical introspection request looks like this:
{
__schema {
queryType { name }
mutationType { name }
types {
name
fields {
name
type { name kind }
}
}
}
}
The response is a machine-readable map of your entire data model. Tools like clairvoyance and graphql-voyager turn that JSON into visual relationship diagrams in seconds. What took days of REST endpoint enumeration now takes one HTTP request.
How Attackers Use Introspection to Enumerate Your Schema
Once an attacker has your schema, they have a detailed blueprint. They can identify mutations that create, update, or delete records. They can find arguments that hint at internal identifiers β userId, adminOverride, internalFlag. They can spot types with names like InternalConfig or DebugInfo that were never intended to be customer-facing.
This reconnaissance stage dramatically accelerates every follow-on attack: CORS misconfigurations, broken authorization checks, and IDOR vulnerabilities are all much easier to find when you have a labeled map of the data model. Skipping introspection hardening means you're handing attackers that map for free.
Even without full introspection enabled, determined attackers can use field-suggestion errors. Many GraphQL servers return messages like "Did you mean 'adminUser'?" when a field name is close to an existing one. This response-based enumeration technique, sometimes called field stuffing, can partially reconstruct a schema even when __schema queries are blocked. You need to suppress suggestion hints in production as well.
Disabling and Restricting Introspection in Production
The simplest fix is to disable introspection entirely in your production environment. Most popular GraphQL server libraries support this with a single flag.
Apollo Server (Node.js):
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
});
graphene (Python / Django):
GRAPHENE = {
'SCHEMA': 'myapp.schema.schema',
'MIDDLEWARE': [],
'INTROSPECTION': False, # Set in production settings
}
If you need introspection available for internal tooling or trusted partners, gate it behind authentication rather than removing it outright. Check for a valid session or a specific internal header before allowing the __schema meta-field to resolve:
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: true, // enabled globally
plugins: [
{
async requestDidStart({ request, contextValue }) {
const isIntrospection = request.query?.includes('__schema') ||
request.query?.includes('__type');
if (isIntrospection && !contextValue.user?.isInternal) {
throw new Error('Introspection not allowed');
}
},
},
],
});
Also configure your server to suppress field suggestions. In Apollo Server you can set includeStacktraceInErrorResponses: false and use a custom formatter to strip suggestion text from error messages before they leave your process.
GraphQL Batching Attacks Explained
GraphQL supports two forms of batching, and attackers abuse both. The first is array batching β sending an array of operation objects in one HTTP POST body instead of a single object. The second is alias batching, where a single GraphQL document uses field aliases to call the same resolver dozens of times in parallel.
Array batching example:
[
{ "query": "mutation { login(email: \"a@evil.com\", password: \"pass1\") { token } }" },
{ "query": "mutation { login(email: \"a@evil.com\", password: \"pass2\") { token } }" },
{ "query": "mutation { login(email: \"a@evil.com\", password: \"pass3\") { token } }" }
]
Alias batching example (within a single document):
mutation {
a1: login(email: "a@evil.com", password: "pass1") { token }
a2: login(email: "a@evil.com", password: "pass2") { token }
a3: login(email: "a@evil.com", password: "pass3") { token }
# ... repeated 100 times
}
A naive rate limiter counting HTTP requests would see one request. The backend sees 100 login attempts. This is how batching turns your credential-stuffing protection into a paper wall. The same technique applies to OTP verification, password resets, and any other resource-intensive or sensitive resolver. Just as OAuth misconfiguration can expose account takeover paths, batch abuse through GraphQL can silently bypass the rate limits you thought were protecting those same accounts.
Defending Against Batching and Alias-Based Abuse
Disable or Limit Array Batching
If you don't need array batching, turn it off. In Apollo Server 4+, batching is disabled by default β confirm you haven't re-enabled it. If you need it for legitimate use cases (like DataLoader patterns across microservices), cap the batch size:
// Express middleware: reject oversized batches before they hit Apollo
app.use('/graphql', (req, res, next) => {
if (Array.isArray(req.body) && req.body.length > 5) {
return res.status(400).json({ error: 'Batch size exceeds limit' });
}
next();
});
Count Operations, Not Requests
Move rate limiting downstream of the HTTP layer. Count individual GraphQL operations β including aliased fields of the same mutation β rather than raw HTTP requests. Libraries like graphql-rate-limit let you annotate resolvers with per-field rate limits using schema directives:
type Mutation {
login(email: String!, password: String!): AuthPayload
@rateLimit(window: "1m", max: 5, identityArgs: ["email"])
}
This limits each unique email to five login attempts per minute regardless of whether those attempts arrive as aliases in one document or as separate requests.
Detect and Block Alias Flooding
Parse the incoming document before execution and count how many times the same field name (ignoring alias) appears at any level. Reject documents that exceed a threshold β five or ten is reasonable for most APIs:
import { parse, visit } from 'graphql';
function detectAliasBatching(query, maxSameField = 10) {
const fieldCounts = {};
const doc = parse(query);
visit(doc, {
Field({ name }) {
fieldCounts[name.value] = (fieldCounts[name.value] || 0) + 1;
if (fieldCounts[name.value] > maxSameField) {
throw new Error(`Alias batching limit exceeded for field: ${name.value}`);
}
},
});
}
Query Depth and Complexity Limiting
Batching isn't the only way to exhaust your server. Deeply nested queries can cause exponential resolver fan-out. Consider a social graph where users have friends, each friend has posts, each post has comments, and each comment has an author. A query nesting six levels deep can trigger thousands of database calls from a single small document.
Depth limiting rejects any document whose AST exceeds a maximum nesting depth before a single resolver fires. Libraries like graphql-depth-limit plug directly into the validation phase:
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(6)],
});
Complexity analysis goes further by assigning a cost to each field and rejecting queries whose total cost exceeds a budget. List fields cost more than scalar fields. Fields that trigger database joins cost more than in-memory lookups. The graphql-query-complexity library integrates with the Apollo plugin system:
import { createComplexityLimitRule } from 'graphql-query-complexity';
const ComplexityLimit = createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10,
onCost: (cost) => console.log('Query cost:', cost),
});
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [ComplexityLimit],
});
Run your most complex legitimate queries through the complexity scorer first to calibrate your budget. Set the limit at roughly two to three times your most expensive expected query, not an arbitrary round number.
Query depth and complexity controls are also your primary defense against a class of attacks sometimes called circular query attacks, where a schema with cyclical type relationships can be exploited to produce infinitely recursive query documents. If your schema has mutual references between types, depth limiting is non-negotiable.
Common Pitfalls When Securing GraphQL APIs
Blocking introspection at the gateway but not the origin. If your API gateway strips introspection queries but the underlying GraphQL server is reachable directly on an internal network, any compromised host inside your perimeter can still enumerate the full schema. Apply controls at the server layer, not only at the edge. This is analogous to the class of SSRF attacks in cloud environments where internal services are reachable despite public-facing controls.
Treating persisted queries as a complete fix. Persisted query allowlisting β where only pre-registered query hashes are accepted β is a strong defense, but it only works if you also reject arbitrary ad-hoc queries entirely. Many teams enable persisted queries for the mobile app but leave the raw endpoint open for development convenience, then forget to close it before release.
Rate limiting by IP address only. Cloud functions and mobile clients often share egress IPs. An attacker behind a NAT or proxy can also rotate IPs cheaply. Rate limit by user identity (JWT sub, session ID, email in mutation arguments) in addition to IP. The same token-handling weaknesses that enable JWT forgery attacks can undermine identity-based rate limiting if token validation isn't airtight.
Not logging rejected queries. Blocked introspection and depth-limit violations are signals, not just noise. Log the raw query, client IP, and any authenticated identity when you reject a request. Attackers probing your schema will generate a recognizable pattern in those logs that a simple alert rule can catch.
Forgetting subscription endpoints. WebSocket-based subscriptions are a separate code path from HTTP queries and mutations. Depth limiting, authentication checks, and complexity rules you've applied to your HTTP handler often don't automatically extend to the subscription handler. Audit both paths independently.
Wrapping Up: Next Steps
GraphQL security isn't a single switch β it's a stack of controls that work together. Here are five concrete actions to prioritize:
- Disable introspection in production today. Add a single environment-check flag if your library supports it. Suppress field suggestions in error responses at the same time.
- Audit whether array batching is enabled and necessary. If you don't need it, turn it off. If you do, cap batch size at the middleware layer before requests reach your resolvers.
- Add depth limiting immediately. It's a one-line change with most libraries and prevents the entire class of recursive query abuse with no performance cost.
- Instrument complexity scoring against your actual query traffic. Set a baseline, then enforce a limit. Start permissive and tighten based on data rather than guessing.
- Move rate limiting to the operation level. Count resolvers and aliases, not HTTP requests. Apply limits by user identity, not just IP, and make sure those controls apply to your subscription endpoint as well.
Once these controls are in place, consider operationalizing them with integration tests that assert a depth-violating query returns a 400 before your team ships each release. Treat your GraphQL security rules the same way you treat schema migrations: versioned, tested, and deployed deliberately.
Frequently Asked Questions
Is it safe to leave GraphQL introspection enabled in a production API?
No. Leaving introspection enabled in production gives any unauthenticated attacker a complete, machine-readable map of your schema, including internal types and sensitive mutations. You should disable it outright or gate it behind strong authentication for trusted internal consumers only.
How does an alias batching attack differ from a normal brute-force attack?
In a normal brute-force attack, each attempt is a separate HTTP request that a rate limiter can count individually. In an alias batching attack, hundreds of attempts are packed into a single GraphQL document as aliased field selections, so a naive HTTP-level rate limiter sees only one request while your resolvers execute every attempt.
Does disabling introspection prevent attackers from discovering my GraphQL schema?
It significantly raises the bar, but it doesn't make discovery impossible. Attackers can still use error-message-based field enumeration, leaked schema files in public repositories, or JavaScript bundle analysis. Disabling introspection is one necessary layer; you should also suppress suggestion hints in error responses and avoid exposing internal type names.
What is a good starting query depth limit for a GraphQL API?
A depth limit of 5 to 7 covers the vast majority of legitimate queries for most applications. Start by running your most deeply nested production queries through a depth checker, then set your limit at one or two levels above the deepest legitimate query you observe.
Can GraphQL persisted queries fully replace rate limiting and complexity controls?
Persisted queries help by allowlisting known-good query shapes, but they don't replace depth limiting or complexity analysis. If an attacker compromises a client or finds an endpoint that still accepts ad-hoc queries, your other controls are the remaining line of defense. Use persisted queries alongside, not instead of, those controls.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!