graphql-query-depth-limit-esm
v2.0.13
Published
GraphQL query depth limiting validation rule with directive support, ignore rules, and fragment cycle detection
Maintainers
Readme
graphql-query-depth-limit-esm
Production-ready GraphQL query depth limiting as a validation rule. Prevents denial-of-service attacks from deeply nested queries by enforcing a configurable maximum depth.
Interactive Demo
Try it live or run locally:
cd examples/visualization
npm install
npm run devPreset queries (simple lookups through deeply nested attacks), an AST tree viewer with depth badges, and a depth gauge with pass/fail verdict.
Features
@depthdirective — per-field depth overrides via schema directives- Alias-aware paths — violation paths use aliases when present, matching the response shape
- Configurable introspection handling — control whether
__schemaand__typecount toward depth - Callback support — optional callback reports per-operation depth for monitoring
- Fragment-safe — handles named fragments, inline fragments, and circular fragment detection
- Ignore rules — skip fields by name, pattern, or custom function
- Interface directive inheritance —
@depthdirectives on interface fields apply to all implementors - Short-circuit traversal — stops immediately on first violation when no callback is provided
- Validation rule — integrates directly with
graphql'svalidate()function - Zero runtime dependencies — only
graphql ^16as a peer dependency
Installation
npm install graphql-query-depth-limit-esm graphqlpnpm add graphql-query-depth-limit-esm graphqlyarn add graphql-query-depth-limit-esm graphqlQuickstart
import { specifiedRules, validate } from "graphql";
import { depthLimit } from "graphql-query-depth-limit-esm";
const errors = validate(schema, document, [
...specifiedRules,
depthLimit(7, { useDirective: true }),
]);When calling validate() directly, include specifiedRules unless you intentionally want to replace GraphQL's default validation rules.
The recommended way to use graphql-query-depth-limit-esm is the @depth directive — a global maximum protects your API, while per-field overrides can tighten limits (and can opt into deeper nesting when directiveMode: "override" is enabled).
Global Limit with Per-Field Overrides
Include depthDirectiveTypeDefs in your schema to declare the @depth directive, then annotate individual fields:
# depthDirectiveTypeDefs provides this automatically:
# directive @depth(max: Int!) on FIELD_DEFINITION
type User {
name: String!
profile: Profile!
# Self-referential — allow up to 3 levels of nesting
friends: [User!]! @depth(max: 3)
}
type Post {
title: String!
author: User!
# Recursive comments — allow up to 5 levels deep
comments: [Comment!]! @depth(max: 5)
}
type Comment {
text: String!
replies: [Comment!]! @depth(max: 4)
}Build the schema with the directive type defs, then enable directive support via depthLimit():
import { makeExecutableSchema } from "@graphql-tools/schema";
import { depthDirectiveTypeDefs, depthLimit } from "graphql-query-depth-limit-esm";
import { specifiedRules, validate } from "graphql";
const schema = makeExecutableSchema({
typeDefs: [depthDirectiveTypeDefs, yourTypeDefs],
resolvers,
});
// Global max of 7 — @depth directives can tighten but never exceed this limit
const errors = validate(schema, document, [
...specifiedRules,
depthLimit(7, { useDirective: true }),
]);Nested Directives
When multiple @depth directives appear along a query path, the effective limit is the strictest (minimum) along that path. A child directive can tighten an ancestor's limit but never relax it:
type Post {
# 3 levels from here (absolute max = currentDepth + 3)
comments: [Comment] @depth(max: 3)
}
type Comment {
text: String
# Wants 5 levels, but capped by the ancestor's limit
replies: [Comment] @depth(max: 5)
}This ensures a parent directive remains a hard ceiling for its entire subtree, preventing deeply nested child directives from punching through ancestor limits.
Note: By default (
directiveMode: "cap"), directives can only tighten the globalmaxDepth, never relax it. If you need directives to override the global limit for specific subtrees, setdirectiveMode: "override". SeedirectiveModefor details.
Interface Directive Inheritance
When a @depth directive is placed on an interface field, it applies to all concrete types implementing that interface — even if the concrete type's field definition has no directive:
interface Node {
children: [Node!]! @depth(max: 3)
}
type TreeNode implements Node {
children: [Node!]! # Inherits @depth(max: 3) from Node interface
label: String!
}When multiple interfaces define @depth on the same field, the strictest (lowest) limit is used.
Why Explicit Opt-In?
The useDirective option is false by default. This is intentional:
- Follows GraphQL Convention. Standard rules like
NoUnusedFragmentsRuleorKnownDirectivesRuleare configured explicitly, and this library follows that same pattern for consistency. - No hidden side effects. Users who only need a global depth cap get exactly that, without the engine scanning every field definition for directives they never added.
- Predictable behavior. Enabling directive support is a deliberate choice, making it clear in code review that per-field overrides are in play.
Usage
Basic Depth Limiting
For straightforward global limiting without per-field overrides, call depthLimit() with only a maximum depth:
import { depthLimit } from "graphql-query-depth-limit-esm";
import { specifiedRules, validate } from "graphql";
// Reject queries deeper than 10 levels
const rule = depthLimit(10);
const errors = validate(schema, document, [...specifiedRules, rule]);With Apollo Server
import { ApolloServer } from "@apollo/server";
import { depthLimit } from "graphql-query-depth-limit-esm";
const server = new ApolloServer({
schema,
validationRules: [depthLimit(7, { useDirective: true })],
});With Yoga
import { createYoga } from "graphql-yoga";
import { depthLimit } from "graphql-query-depth-limit-esm";
const yoga = createYoga({
schema,
plugins: [
{
onValidate({ addValidationRule }) {
addValidationRule(depthLimit(7, { useDirective: true }));
},
},
],
});Ignore Rules
Skip specific fields during depth calculation using strings, regular expressions, or custom functions. See IgnoreRule for the full type definition.
const rule = depthLimit(5, {
ignore: [
// Exact field name match
"metadata",
// Regular expression pattern
/.*Connection$/,
// Custom function
(fieldName) => fieldName.startsWith("internal"),
],
});Warning: When
ignoreMode: "skip"is set and a field matches an ignore rule, its entire subtree is skipped — not just the depth increment for that field. This means all children, grandchildren, etc. are excluded from depth calculation entirely. Use ignore rules carefully on composite fields, as deeply nested subtrees under an ignored field will bypass depth protection.
Ignore Mode
By default, ignored fields only skip the depth increment while still traversing children. Use ignoreMode to control this behavior:
// Default (secure): skip only the depth increment, still traverse children
const rule = depthLimit(5, { ignore: ["metadata"], ignoreMode: "exclude" });
// Optional: skip the field and its entire subtree (use with caution)
const rule = depthLimit(5, { ignore: ["metadata"], ignoreMode: "skip" });With "exclude", the ignored field does not increment the depth counter, but its children are still traversed and subject to depth limits. This prevents attackers from nesting arbitrarily deep queries under ignored composite fields.
If the ignored field is recursive (for example, friends -> friends), depth can remain flat along that edge because the ignored field keeps suppressing increments. To prevent this, enable limitIgnoredRecursion, use ignoreMode: "skip", or cap the field with @depth.
Recursive Ignore Guard
Enable limitIgnoredRecursion to prevent unbounded traversal when using ignoreMode: "exclude" on recursive fields:
const rule = depthLimit(10, {
ignore: ["friends"],
ignoreMode: "exclude",
limitIgnoredRecursion: true,
});When enabled, the first ignored field occurrence on a path is still excluded, but repeated occurrences on the same path increment depth normally.
Short-Circuit Control
By default, short-circuit behavior is auto-detected:
- No callback: short-circuits on first violation (
shortCircuit: true) - Callback provided: traverses full tree for exact depth (
shortCircuit: false)
You can override this explicitly:
// Force full traversal even without a callback
const exactRule = depthLimit(5, { shortCircuit: false });
// Force early bailout even with a callback
const fastRule = depthLimit(5, { shortCircuit: true }, (depths) => {
console.log(depths);
});Case-Insensitive Matching
Enable caseInsensitiveIgnore to match string ignore rules regardless of casing:
const rule = depthLimit(5, {
caseInsensitiveIgnore: true,
ignore: ["metadata"], // Matches "metadata", "Metadata", "METADATA", etc.
});Introspection Handling
By default, only __typename is ignored during depth calculation. The __schema and __type fields are counted toward depth, protecting against deeply nested introspection queries.
Note that scalar introspection fields like __typename never increment depth on their own (only composite fields with nested selections do). The ignoreIntrospection setting controls whether these fields are recognized as ignored, which matters for the ignoreMode behavior when they appear within composite selections.
// Default: only __typename is ignored
const rule = depthLimit(10);
// Ignore all introspection fields and skip their entire subtree
const rule = depthLimit(10, { ignoreIntrospection: "all" });
// Count all fields toward depth, including __typename
const rule = depthLimit(10, { ignoreIntrospection: "none" });Note: When
ignoreIntrospection: "all"is set, introspection fields and their entire subtree are always skipped — regardless ofignoreMode. This is a security hardening mode that completely eliminates introspection from depth calculation.
Depth Callback
Monitor query depths with an optional callback that receives per-operation depth results:
const rule = depthLimit(10, {}, (depths) => {
// { "GetUser": 3, "ListPosts": 5, "[anonymous]": 2 }
for (const [operation, depth] of Object.entries(depths)) {
console.log(`Operation "${operation}" has depth ${depth}`);
}
});If you do not need options, you can pass the callback as the second argument:
const rule = depthLimit(10, (depths) => {
console.log(depths);
});Note: By default, providing a callback disables short-circuiting so the engine can report accurate maximum depths. If you explicitly set
shortCircuit: true, traversal still bails early and reported depths are lower bounds ("at least N"). Without a callback, the engine short-circuits by default for maximum performance.
API Reference
depthLimit(maxDepth, options?, callback?)
Creates a GraphQL validation rule that limits query depth.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| maxDepth | number | Yes | Maximum allowed depth (non-negative integer) |
| options | DepthLimitOptions | No | Configuration options |
| callback | DepthCallback | No | Called with per-operation depth results |
Returns
A GraphQL ValidationRule function.
Throws
ErrorifmaxDepthis not a non-negative integerTypeErrorif options, callback, or ignore rules are invalid
depthDirectiveTypeDefs
GraphQL SDL string defining the @depth directive. Include this in your schema type definitions when using { useDirective: true }.
directive @depth(max: Int!) on FIELD_DEFINITIONDepthLimitOptions
| Property | Type | Default | Description |
|---|---|---|---|
| caseInsensitiveIgnore | boolean | false | Case-insensitive matching for string ignore rules |
| directiveMode | DirectiveMode | "cap" | Controls how @depth directives interact with the global maxDepth |
| ignore | IgnoreRule \| IgnoreRule[] | undefined | Fields to skip during depth calculation |
| ignoreIntrospection | IntrospectionMode | "typename" | Controls which introspection fields are ignored |
| ignoreMode | IgnoreMode | "exclude" | Controls whether ignored fields skip their entire subtree or only the depth increment |
| limitIgnoredRecursion | boolean | false | In ignoreMode: "exclude", increments depth for repeated ignored fields on the same path to prevent unbounded recursion |
| shortCircuit | boolean | undefined (auto) | Auto: true without callback, false with callback. Set explicitly to force early bailout or full traversal |
| useDirective | boolean | false | Read @depth(max: Int!) directives from field definitions. Requires a schema in validation context; without one, directive resolution falls back to the global maxDepth. |
DirectiveMode
type DirectiveMode = "cap" | "override";"cap"— directives can only tighten the limit below the globalmaxDepth(secure default)"override"— the first directive replaces the global limit for its subtree
IgnoreMode
type IgnoreMode = "exclude" | "skip";"exclude"— skip the depth increment but still traverse children (secure default)"skip"— skip the field and its entire subtree
IntrospectionMode
type IntrospectionMode = "all" | "none" | "typename";"all"— ignore every__-prefixed field (__typename,__schema,__type, etc.)"typename"— only ignore__typename(secure default)"none"— count all introspection fields toward depth
IgnoreRule
type IgnoreRule = string | RegExp | ((fieldName: string) => boolean);DepthCallback
type DepthCallback = (depths: Record<string, number>) => void;ERROR_CODES
const ERROR_CODES = {
IGNORE_RULE_ERROR: "IGNORE_RULE_ERROR",
QUERY_TOO_DEEP: "QUERY_TOO_DEEP",
} as const;QUERY_TOO_DEEP: query depth exceeded the allowed limitIGNORE_RULE_ERROR: a user-provided ignore rule (RegExpor function) threw at validation time
Error Extensions
Depth violations include structured extensions for programmatic access:
{
"message": "'GetUser' has depth 8 which exceeds maximum allowed depth of 5 (at user.friends.friends)",
"extensions": {
"code": "QUERY_TOO_DEEP",
"depth": 8,
"maxDepth": 5,
"path": ["user", "friends", "friends"],
"shortCircuit": false
}
}| Field | Type | Description |
|---|---|---|
| code | string | Always "QUERY_TOO_DEEP" |
| depth | number | The query's calculated depth. If shortCircuit is true, this is the depth where the violation was detected, which may not be the absolute maximum depth. |
| maxDepth | number | The maximum allowed depth that was exceeded |
| path | string[] | Field path from the operation root to the violation point (uses aliases when present) |
| shortCircuit | boolean | Whether the engine short-circuited. If true, depth is a lower bound ("at least N"), including when shortCircuit: true is explicitly forced with a callback. |
If an ignore rule throws, validation reports:
{
"message": "Ignore rule function threw for field \"friends\": boom",
"extensions": {
"code": "IGNORE_RULE_ERROR"
}
}How Depth Is Calculated
- Depth increments for each composite field (objects, interfaces, unions)
- Scalar and enum fields do not increment depth
- Fragment spreads contribute the depth of their expanded selections
- Inline fragments contribute the depth of their selections
__typenameis ignored by default (configurable viaignoreIntrospection)__schemaand__typeare counted toward depth by default- Circular fragment references are detected per-path and stop recursion
Example
# Depth: 0
query {
# Depth: 1
user {
name # Depth: 1 (scalar, no increment)
# Depth: 2
posts {
title # Depth: 2 (scalar, no increment)
# Depth: 3
comments {
text # Depth: 3 (scalar, no increment)
}
}
}
}
# Maximum depth: 3Performance
The depth engine uses iterative DFS (not recursion) to prevent stack overflow on adversarial queries and to keep traversal overhead constant regardless of nesting depth.
| Characteristic | Detail |
|---|---|
| Algorithm | Iterative depth-first search with explicit stack |
| Time complexity | O(n) where n = total selection nodes in the query |
| Short-circuit | Stops on first violation when no callback is provided |
| Fragment cycles | Detected per-path with minimal Set allocations |
| Stack safety | No recursion — immune to stack overflow on deep queries |
| Runtime dependencies | Zero — only graphql as a peer dependency |
Typical validation completes in sub-millisecond time for standard queries (< 50 fields). Even adversarial queries with hundreds of nested selections are analyzed in single-digit milliseconds thanks to early termination and per-path fragment cycle detection.
Migrating from v1 to v2
v2 introduces three breaking changes with more secure defaults:
1. Introspection fields are no longer fully ignored
v1: All __-prefixed fields (__typename, __schema, __type) were ignored during depth calculation.
v2: Only __typename is ignored by default. __schema and __type now count toward depth, preventing deeply nested introspection queries from bypassing the depth limit.
To restore v1 behavior:
depthLimit(10, { ignoreIntrospection: "all" });2. @depth directives can no longer relax the global limit
v1: A @depth(max: 50) directive could override a global maxDepth: 10, allowing that subtree to nest up to 50 levels deep.
v2: By default (directiveMode: "cap"), directives can only tighten below the global max. A @depth(max: 50) with maxDepth: 10 caps at 10.
To restore v1 behavior:
depthLimit(10, { directiveMode: "override", useDirective: true });3. Short-circuit traversal on violations
v1: The engine always traversed the full query, even after detecting a violation.
v2: When no callback is provided, the engine stops traversal immediately on the first violation. This is a performance improvement and DoS protection — a deeply nested query with a small maxDepth no longer burns CPU traversing thousands of levels.
Impact: This is transparent to most users. The only observable difference is that error messages may report the depth at the first violation rather than the deepest violation when multiple branches exceed the limit. If you need the true maximum depth, provide a callback.
Related Packages
This package is part of a suite of GraphQL security tools that work independently or together to protect your API:
| Package | Purpose |
|---|---|
| graphql-query-complexity-esm | Complexity analysis — assign cost scores to fields and reject expensive queries |
| graphql-rate-limit-redis-esm | Rate limiting — Redis-backed per-field rate limiting via @rateLimit directive |
Recommended layering: Use depth limiting as a fast, cheap first gate, complexity analysis for fine-grained cost control, and rate limiting for per-client throttling.
