A GraphQL server without depth and complexity limits is an amplification attack waiting to happen. A single deeply nested query can trigger thousands of database resolver calls because GraphQL resolves each field independently. The attacker writes a query once; the server does the multiplication. This post covers the specific configuration for the three most common GraphQL server implementations -- Apollo Server, graphql-go, and Strawberry Python -- and provides test queries to verify the limits are working.
Why Depth Limits Alone Are Insufficient
Depth limits prevent unbounded nesting, but they do not prevent breadth amplification. A query with depth 5 but 100 sibling fields at each level can still trigger 100^5 = 10 billion resolver invocations in the worst case. Complexity limits are required in addition to depth limits. Complexity assigns a cost to each field and rejects queries that exceed a total cost threshold. The combination of depth + complexity + per-query timeout provides adequate protection.
Apollo Server Configuration
// Apollo Server v4 with depth and complexity limits
import { ApolloServer } from '@apollo/server';
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
// Reject queries deeper than 5 levels
depthLimit(5, { ignore: ['__schema', '__type'] }),
// Reject queries with complexity above 1000
// Default cost per field: 1
// List field multiplier: configured per-field
createComplexityLimitRule(1000, {
onCost: (cost) => {
console.log('Query complexity:', cost);
},
formatErrorMessage: (cost) =>
`Query complexity ${cost} exceeds limit of 1000`
})
],
// Introspection disabled in production
introspection: process.env.NODE_ENV !== 'production'
});
graphql-go Configuration
// graphql-go with depth limit middleware
package main
import (
"github.com/graph-gophers/graphql-go"
"github.com/graph-gophers/graphql-go/relay"
)
func main() {
opts := []graphql.SchemaOpt{
graphql.MaxDepth(5),
graphql.MaxParallelism(10), // Limit concurrent resolver goroutines
}
schema := graphql.MustParseSchema(schemaString, &resolver{}, opts...)
http.Handle("/query", &relay.Handler{Schema: schema})
}
Strawberry Python Configuration
# Strawberry Python with depth and complexity limits
import strawberry
from strawberry.extensions import QueryDepthLimiter, AddValidationRules
from graphql import GraphQLError
def check_query_complexity(complexity):
if complexity > 1000:
raise GraphQLError(f"Query complexity {complexity} exceeds limit of 1000")
schema = strawberry.Schema(
query=Query,
extensions=[
QueryDepthLimiter(max_depth=5),
]
)
# FastAPI integration with timeout
from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter
app = FastAPI()
graphql_app = GraphQLRouter(
schema,
# Request timeout prevents long-running queries
execution_context_class=TimedExecutionContext # custom class
)
Test Queries for Verifying Depth Limits
# Test query that should be REJECTED by depth limit (depth = 7)
query DepthTest {
user(id: "1") { # depth 1
friends { # depth 2
friends { # depth 3
posts { # depth 4
comments { # depth 5
author { # depth 6
username # depth 7 - SHOULD REJECT
}
}
}
}
}
}
}
# Expected response:
# {"errors": [{"message": "Syntax Error: Field 'username' exceeds maximum operation depth of 5"}]}
Testing from DDactic
DDactic's GraphQL assessment sends a graduated series of depth-increasing queries and records whether each returns an error at the expected depth. It also tests complexity limits with width-increasing queries (many fields at moderate depth) and verifies that introspection is disabled in production. A passing result confirms that the server rejects depth-7 queries, complexity-1000+ queries, and introspection queries.
Test Your GraphQL Depth Limits
DDactic's scanner probes your GraphQL endpoints with graduated depth and complexity queries to verify limits are enforced.
Run a Free Scan