GraphQL Query Complexity and Depth Limiting
GraphQL's power is also its security risk. A single query can ask for deeply nested data or thousands of related items, making your server do enormous amounts of work. Query complexity and depth limiting protect your server from abusive or accidental queries that could bring it down.
The Attack Query Problem
A valid but devastating query:
────────────────────────────────
{
users { ← All users (millions?)
friends { ← Each user's friends
friends { ← Each friend's friends
friends { ← Each friend's friends' friends
posts { ← All posts for each
comments { ← All comments for each post
author {
friends { name }
}
}
}
}
}
}
}
}
This query is syntactically valid.
It could trigger billions of database calls.
Without limits: server crashes or DB melts.
Depth Limiting
Depth limiting rejects any query where the nesting level exceeds a defined maximum. Setting a depth limit of 5 means a query can nest at most 5 levels deep. Anything deeper is rejected before any resolver runs.
npm install graphql-depth-limit
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)]
});
Depth counting:
────────────────
{ users { ← depth 1
friends { ← depth 2
friends { ← depth 3
friends { ← depth 4
name ← depth 5 ✓ allowed
posts { ← depth 6 ✗ REJECTED
}
}
}
}
}
Query Complexity Limiting
Depth limits alone are not enough. A query that fetches 10,000 users with just 2 levels of nesting can still overload your server. Complexity limiting assigns a cost to each field and rejects queries whose total cost exceeds a threshold.
npm install graphql-query-complexity
import {
createComplexityLimitRule,
simpleEstimator,
fieldExtensionsEstimator,
} from 'graphql-query-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityLimitRule(1000, { ← Max complexity: 1000
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 })
]
})
]
});
// Assign costs in schema extensions:
type Query {
users: [User] @complexity(value: 5, multipliers: ["limit"])
user(id: ID!): User @complexity(value: 1)
}
Complexity calculation example:
────────────────────────────────
{ users(limit: 100) { name posts { title } } }
users: 5 × 100 (limit multiplier) = 500
User.name: 1 × 100 = 100
User.posts: 1 × 100 = 100
Post.title: 1 × 100 (estimated posts) = 100
Total: = 800 ✓ under 1000
Disabling Introspection in Production
GraphQL introspection lets anyone query your entire schema. In development this is great for tools. In production it hands attackers a roadmap to your API. Disable it unless you have a reason to keep it on.
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production'
});
Query Timeout
Even with complexity and depth limits, set a wall-clock timeout so a slow resolver cannot hold a connection open indefinitely.
// Express middleware example:
app.use('/graphql', timeout('10s'), (req, res, next) => {
if (!req.timedout) next();
});
Key Points
- GraphQL queries are valid syntactically but can be destructively expensive without limits.
- Depth limiting rejects queries nested deeper than a defined maximum level.
- Complexity limiting assigns a cost to each field and rejects queries whose total cost exceeds a threshold.
- Disable introspection in production to prevent schema discovery by attackers.
- Set a request timeout as a final safety net against runaway resolvers.
