npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

graphql-query-depth-limit-esm

v2.0.13

Published

GraphQL query depth limiting validation rule with directive support, ignore rules, and fragment cycle detection

Readme

graphql-query-depth-limit-esm

CI Publish Pages npm version npm downloads License: MIT Node >=20 Provenance

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 dev

Preset queries (simple lookups through deeply nested attacks), an AST tree viewer with depth badges, and a depth gauge with pass/fail verdict.

Features

  • @depth directive — 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 __schema and __type count 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@depth directives 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's validate() function
  • Zero runtime dependencies — only graphql ^16 as a peer dependency

Installation

npm install graphql-query-depth-limit-esm graphql
pnpm add graphql-query-depth-limit-esm graphql
yarn add graphql-query-depth-limit-esm graphql

Quickstart

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 global maxDepth, never relax it. If you need directives to override the global limit for specific subtrees, set directiveMode: "override". See directiveMode for 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 NoUnusedFragmentsRule or KnownDirectivesRule are 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 of ignoreMode. 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

  • Error if maxDepth is not a non-negative integer
  • TypeError if 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_DEFINITION

DepthLimitOptions

| 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 global maxDepth (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 limit
  • IGNORE_RULE_ERROR: a user-provided ignore rule (RegExp or 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
  • __typename is ignored by default (configurable via ignoreIntrospection)
  • __schema and __type are 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: 3

Performance

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.

License

MIT