Siddhant Deval
Siddhant Deval
backend19 min read

Schema Security: Depth Limits, Complexity Budgets & Attack Surfaces

Every field in your GraphQL schema is a potential attack surface — recursive traversal enables DoS, alias amplification enables field-level abuse, and introspection enables reconnaissance. Depth limits, complexity budgets, and persisted operations are the minimum viable protection.

Schema Security: Depth Limits, Complexity Budgets & Attack Surfaces

You own the schema and the resolvers — the schema is a public contract you can never silently break, and every resolver is a performance commitment you make on every query. The attack surface of a GraphQL API begins with the schema itself: a single introspection query reveals every type, every field, every argument, and every relationship in your graph. From that map, an attacker can construct queries that your application never intended to handle — recursive queries that exhaust memory, alias-amplified mutations that bypass rate limits, and argument permutations that probe your resolver error paths. The discipline of GraphQL security is not firewall rules and WAF signatures; it is schema design decisions that structurally prevent these queries from being expressed.

Architectural Note

This is Part 6 of the GraphQL Backend & API Design series. It connects to Part 1 (Schema Design) for the @requiresScopes directive, and to Part 7 (Schema Evolution) for persisted operations as an evolution-safe security boundary.


1. The Attack Surface Taxonomy

Three categories of GraphQL-specific attack vectors exist before any application-layer logic runs:

1.1 Recursive Traversal DoS

GRAPHQL
# ❌ A query that recursively traverses circular references until memory exhaustion
# If your schema allows: User → orders → user → orders → user...
query RecursiveDoS {
  user(id: "1") {
    orders {
      user {
        orders {
          user {
            orders {
              user { name } # Depth 7 — already fetching exponentially
            }
          }
        }
      }
    }
  }
}

Without a depth limit, this query executes until the process runs out of memory or the DB connection pool is exhausted. One client, one query, one process crash.

1.2 Alias Amplification

GRAPHQL
# ❌ Aliases bypass naive field-level rate limiting
# Each alias is a separate resolver execution — this fires 100 DB queries
query AliasAmplification {
  a1: user(id: "1") { orders { total } }
  a2: user(id: "1") { orders { total } }
  a3: user(id: "1") { orders { total } }
  # ... a100: same query
}

Per-field rate limiting counts user as "1 call." Alias amplification fires 100 resolver executions while looking like 1 field to naïve rate limiters.

1.3 Introspection Reconnaissance

GRAPHQL
# ❌ Any client can dump your entire schema without authentication
query SchemaRecon {
  __schema {
    types {
      name
      fields { name type { name kind } }
    }
  }
}

The introspection result reveals every internal type name, every admin-only mutation, every argument, and every @deprecated field — a complete map for a targeted attack.


2. The Three-Gate Defense Model

Effective GraphQL security requires three sequential validation gates, each blocking a different attack class:

2.1 Gate 1: Query Depth Limit

TYPESCRIPT
// ✅ graphql-depth-limit — blocks recursive traversal DoS
import depthLimit from 'graphql-depth-limit';
import { createYoga } from 'graphql-yoga';

const yoga = createYoga({
  schema,
  validationRules: [
    depthLimit(
      7,  // Maximum allowed query depth — count from root field to deepest leaf
      { ignore: ['__schema', '__type'] }, // Allow introspection queries (disable in prod)
      (depths) => {
        // Optional: emit a metric for depth distribution monitoring
        console.log('Query depths:', depths);
      }
    ),
  ],
});

Choosing the right depth limit: audit your actual client queries. Most production UIs have a maximum natural depth of 5–6 (root → entity → relationship → sub-field → scalar). Setting the limit at 7 allows all legitimate queries while blocking recursive amplification.

2.2 Gate 2: Complexity Budget

TYPESCRIPT
// ✅ graphql-query-complexity — per-query computational cost budget
import { createComplexityRule } from 'graphql-query-complexity';

const yoga = createYoga({
  schema,
  validationRules: [
    createComplexityRule({
      maximumComplexity: 1000,
      variables: {},
      onComplete: (complexity) => {
        console.log('Query complexity:', complexity);
        // Emit as a metric for p95 complexity monitoring
      },
      estimators: [
        // Per-field cost assignment
        fieldExtensionsEstimator(),       // Uses @complexity directive on schema fields
        simpleEstimator({ defaultComplexity: 1 }),  // Each field costs 1 by default
      ],
    }),
  ],
});
GRAPHQL
# Schema-level complexity annotations
type Query {
  # Expensive fields get higher costs
  users(first: Int): [User!]! @complexity(value: 5, multipliers: ["first"])
  # Standard fields use the default cost of 1
  user(id: ID!): User
}

2.3 Gate 3: Persisted Operations Manifest

Persisted operations are the strongest security gate: in production, only pre-registered operation documents are allowed to execute. Any novel query — including all attack queries — is rejected without parsing or execution.

TYPESCRIPT
// ✅ Apollo Router persisted operations manifest (router.yaml)
YAML
# router.yaml
persisted_queries:
  enabled: true
  log_unknown: true   # Log attempts to execute unregistered operations
  safelist:
    enabled: true     # In safelist mode, only registered operations execute
    require_id: true  # Clients must send the operation ID, not the full document
BASH
# Register operations from client bundles during build
rover persisted-queries publish my-graph \
  --manifest operations-manifest.json
Crucial Requirement

Persisted operations break the assumption that clients send arbitrary GraphQL documents. In safelisted mode, no unregistered query can execute — recursive traversal attacks, alias amplification, and introspection recon are all structurally impossible, not just rate-limited.


3. Introspection Access Control

TYPESCRIPT
// ✅ Disable introspection in production for non-authenticated requests
// graphql-armor v3 provides this as a plugin rule
import { createYoga } from 'graphql-yoga';
import { EnvelopArmorPlugin } from '@escape.tech/graphql-armor';

const yoga = createYoga({
  schema,
  plugins: [
    EnvelopArmorPlugin({
      blockFieldSuggestion: { enabled: true },      // No "Did you mean X?" leaks
      maxDepth: { enabled: true, n: 7 },            // Gate 1
      maxTokens: { enabled: true, n: 1000 },        // Gate 2 (token-based)
      costLimit: { enabled: true, maxCost: 5000 },  // Gate 2 (cost-based)
      // Introspection: allow for authenticated users, block for public
      introspectionBlockList: {
        enabled: process.env.NODE_ENV === 'production',
      },
    }),
  ],
  context: async (req) => {
    const user = await authenticateRequest(req);
    return { user };
  },
});

Alternatively, with Apollo Router's @requiresScopes:

GRAPHQL
# Federation 2.x: gate introspection and admin mutations behind OAuth scopes
type Mutation {
  deleteUser(id: ID!): Boolean! @requiresScopes(scopes: [["admin"]])
  createOrganization(input: OrgInput!): Org! @requiresScopes(scopes: [["admin"], ["org:write"]])
}

4. graphql-armor v3 — Drop-In Security Middleware

graphql-armor v3 is a plugin for graphql-yoga, apollo-server, and envelop that enables all three security gates with one configuration object:

TYPESCRIPT
// ✅ graphql-armor v3 — all security gates in one plugin
import { createYoga } from 'graphql-yoga';
import { EnvelopArmorPlugin } from '@escape.tech/graphql-armor';

const yoga = createYoga({
  schema,
  plugins: [
    EnvelopArmorPlugin({
      maxDepth: {
        enabled: true,
        n: 7,                     // Maximum query depth
        flattenFragments: true,   // Count fragment spreads against depth
      },
      maxAliases: {
        enabled: true,
        n: 15,                    // Maximum aliases per query (blocks alias amplification)
      },
      maxDirectives: {
        enabled: true,
        n: 50,
      },
      costLimit: {
        enabled: true,
        maxCost: 5000,
        objectCost: 2,            // Object types cost more than scalar fields
        scalarCost: 1,
        depthCostFactor: 1.5,     // Cost multiplies by depth (deeper = more expensive)
      },
      blockFieldSuggestion: {
        enabled: true,            // Prevents "Did you mean 'adminUsers'?" leaks
      },
    }),
  ],
});

5. Rate Limiting by Operation Name

IP-based rate limiting is insufficient for GraphQL — the same IP can send high-cost and low-cost operations. Rate limit by operationName instead:

TYPESCRIPT
// ✅ Per-operation-name rate limiting with ioredis
import { RateLimiterRedis } from 'rate-limiter-flexible';

const operationRateLimiter = new RateLimiterRedis({
  storeClient: redis,
  keyPrefix: 'rl:op',
  points: 100,      // Max 100 executions per window
  duration: 60,     // Per 60 seconds
});

// In GraphQL context or middleware:
async function checkOperationRateLimit(operationName: string, userId: string) {
  const key = `${userId}:${operationName}`;
  try {
    await operationRateLimiter.consume(key, 1);
  } catch {
    throw new GraphQLError('Rate limit exceeded for this operation', {
      extensions: { code: 'TOO_MANY_REQUESTS', operationName },
    });
  }
}

Flow Trace: three-gate model — incoming query through depth gate → complexity gate → persisted operations manifest check
Flow Trace: three-gate model — incoming query through depth gate → complexity gate → persisted operations manifest check

Three sequential validation gates prevent the three main attack classes: depth limits block recursive traversal DoS, complexity budgets block alias amplification, and persisted operations safelisting blocks all novel attack queries before parsing.

Comparison Matrix: no protection vs. depth-limit only vs. complexity + aliases vs. persisted operations vs. all three gates
Comparison Matrix: no protection vs. depth-limit only vs. complexity + aliases vs. persisted operations vs. all three gates

Persisted operations alone provide the highest security guarantee but require build-time coordination. Defense-in-depth with all three gates is the production standard: each gate catches a different attack class.


Summary

Concept Rule
Introspection Disable in production for unauthenticated requests — the introspection response is a complete attack map. Allow for authenticated internal tools and developer environments only.
Depth limit Set to your application's maximum natural query depth + 2. Most production UIs have a natural depth of 5–6; a limit of 7–8 blocks all recursive attacks while allowing all legitimate queries.
Complexity budget Per-field cost × depth factor prevents alias amplification and expensive field combinations. maxAliases: 15 in graphql-armor is a direct alias amplification blocker.
Persisted operations In safelisted mode, only pre-registered operation documents execute — recursive attacks, alias amplification, and introspection recon are structurally impossible regardless of depth or complexity limits.
Rate limit by operationName IP-based rate limiting is bypassed by operation mixing. Key rate limiters on userId:operationName to enforce per-operation fairness.

What's Next

Part 7 — Schema Evolution: Breaking Changes, Deprecation & Migration Windows covers the full lifecycle of a field from birth to removal: the additive-only rule, @deprecated sunset message format, CI-enforced breaking change detection with rover graph check, and the phased migration playbook that makes field removal safe.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#GraphQL#API Security#Rate Limiting#Persisted Operations#graphql-armor
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.