← News
/PLAYBOOK · 2026-08-18

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

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.