GraphQL API Security Hardening: Depth Query and Batch Attack Defense Config
GraphQL's single-endpoint design amplifies DDoS and data-leak risk. Covers depth queries, batch queries, and Introspection probing, plus full Apollo Server rate-limit and gateway defense config.
Scope
GraphQL APIs are replacing REST. Single endpoint plus arbitrary nested queries open a new attack surface. Applies when: you serve business APIs with Apollo Server, GraphQL Yoga, or Hasura; ecommerce/SaaS teams worry depth-nested queries will flatten the backend DB; third-party developers need query-complexity and call-rate caps. Audience: backend and security ops. Close GraphQL-specific paths without cutting features.
Prerequisites
- Node.js 16+, existing GraphQL service (Apollo Server 4 in this writeup)
- GraphQL Schema, Query, Resolver basics
- Optional: Nginx reverse proxy for gateway rate limits
How It Works
Three GraphQL attack classes: depth queries abuse relational fields for unbounded nesting (user.friends.friends...) so one request triggers exponential DB queries and exhausts CPU and connection pools; batch queries use `__typename` aliases to run hundreds of identical queries in one request, bypassing per-request rate limits; Introspection uses `__schema` to dump the full Schema and map the attack surface. Defense: cap depth and complexity; rate-limit by operation count not request count; disable Introspection in production.
Procedure
Step 1: Limit query depth
Install and configure `graphql-depth-limit`:
npm install graphql-depth-limit
// server.js
const depthLimit = require('graphql-depth-limit');
const { ApolloServer } = require('@apollo/server');
const server = new ApolloServer({
typeDefs, resolvers,
validationRules: [depthLimit(5)], // max nesting depth 5 levels
});
Deep queries fail validation and never hit Resolvers.
Step 2: Limit query complexity
Install `graphql-validation-complexity` and set cost coefficients:
npm install graphql-validation-complexity
const { createComplexityLimitRule } = require('graphql-validation-complexity');
const server = new ApolloServer({
typeDefs, resolvers,
validationRules: [
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 5,
listFactor: 10, // list fields billed at 10x
}),
],
});
High-cost queries (multi-level list nesting) are rejected before execution with The query exceeds the maximum allowed complexity.
Step 3: Disable Introspection in production
const server = new ApolloServer({
typeDefs, resolvers,
validationRules: [],
introspection: process.env.NODE_ENV !== 'production',
});
Also intercept at the gateway: `__schema` / `__type` → 403:
# Nginx: block Introspection requests
if ($request_body ~* "__schema|__type") {
return 403;
}
Step 4: Rate-limit by operation count (alias batch)
Use `@graphql-tools/utils` to count operations per request and throttle:
npm install graphql-rate-limit-directive
// custom middleware: count top-level fields
const countOps = (query) => {
const matches = query.match(/\b[a-zA-Z_][a-zA-Z0-9_]*\s*\(/g);
return matches ? matches.length : 1;
};
// count in context; reject if over threshold
app.use('/graphql', (req, res, next) => {
const ops = countOps(req.body?.query || '');
if (ops > 50) {
return res.status(429).json({ error: 'Too many operations in one request' });
}
next();
});
Step 5: Global rate limit and concurrency
npm install rate-limit-redis ioredis
const { rateLimit } = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 60 * 1000, // 1-minute window
max: 60, // max 60 per IP
keyGenerator: (req) => req.ip,
standardHeaders: true,
});
app.use('/graphql', limiter);
Validation
# 1. Depth limit validation: construct a 10-level nested query; should return a validation error
curl -X POST http://localhost:4000/graphql \
-H "Content-Type: application/json" \
-d '{"query":"query { user { friends { friends { friends { friends { friends { friends { id } } } } } } } }"}'
# expected response: "Anonymous query is too deep"
# 2. Complexity limit validation: high-cost query should be rejected
curl -X POST http://localhost:4000/graphql \
-H "Content-Type: application/json" \
-d '{"query":"query { users { posts { comments { id } } } }"}'
# 3. Introspection validation: production returns 403 or disabled
curl -X POST http://localhost:4000/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ __schema { types { name } } }"}'
# 4. Batch operation rate-limit validation: 100 aliases in a single request should return 429
python3 -c "
import json, urllib.request
q = 'query { ' + ' '.join(['a%d: __typename' % i for i in range(100)]) + ' }'
data = json.dumps({'query': q}).encode()
req = urllib.request.Request('http://localhost:4000/graphql', data=data, headers={'Content-Type': 'application/json'})
try:
urllib.request.urlopen(req)
except urllib.error.HTTPError as e:
print('HTTP', e.code)
"
FAQ
FAQ 1: Will depth limits break normal traffic?
Yes. Tradeoff. Depth 5 is enough for most apps; complex reports or social-graph queries may false-positive. Enable logging first (Apollo `didEncounterErrors`) for a week, then tighten. Or `@deprecated` specific fields and force client refactors instead of raising the cap.
FAQ 2: Attackers rotate IPs after rate limit?
Single-IP limits will be bypassed. Production needs layers: rate-limit by API Key / user Token (Redis distributed counters); behavior analytics on anonymous traffic for high-frequency alias-batch signatures; CDN/WAF bot management and device fingerprinting. Block automation, not just IPs.
Summary
GraphQL defense is about quantifying query power: depth caps nesting, complexity scores resource cost, operation-count limits kill alias batches, Introspection off shrinks the attack surface. Tighten in order: depth → complexity → op count → global rate limit. Log false-positive rate at each step. Gateway (Nginx/cloud WAF) adds request frequency and bot defense.