Skip to main content

GraphQL Guard

The graphql engine protects GraphQL endpoints against query-shape denial of service: deeply nested queries, alias overloading, root/total field explosions, batched-operation floods, fragment bombs, and schema introspection. It is a body-phase, request-only engine — it parses the query once and enforces the configured limits, with two always-on backstops that cannot be disabled.

It targets both delivery paths: a POST with a matching content-type (within the optional path allow-list) and a GET carrying ?query= — guarding only POST would let an attacker move a deep query to GET. Everything else passes through with no penalty.

info

This is not a positive-security gate — a request that doesn't look like GraphQL (including a body that isn't parseable as JSON) simply passes through. Pair it with OpenAPI validation or the Coraza WAF for positive security.

When to use it

  • Any exposed GraphQL endpoint — GraphQL's expressiveness makes tiny requests capable of enormous server-side work, which none of the generic engines understand.
  • Block introspection (__schema / __type) on production APIs.
  • Cap batched operations, nesting depth, and alias fan-out to match what your real clients actually send.

Configuration

FieldTypeDefaultNotes
content_typesstring[]application/json, application/graphqlBodies treated as GraphQL. Also inspects GraphQL-over-GET.
pathsstring[]Restrict to these paths (empty = any).
max_depthint0 (off)Max query nesting depth.
max_aliasesint0 (off)Max aliases.
max_root_fieldsint0 (off)Max root fields (counted through fragments).
max_total_fieldsint0 (off)Max total fields.
max_operationsint0 (off)Max operations per document (batching).
block_introspectionboolfalseBlock introspection queries.
max_fragment_depthint32Fragment-spread recursion bound (DoS).
max_complexityint100000Per-operation node-visit budget. Always enforced as a backstop (0 falls back to the default, it does NOT disable it).

Rule: at least one of max_depth / max_aliases / max_root_fields / max_total_fields / max_operations or block_introspection is required (a zero individual limit disables only that check).

Example

apiVersion: sentinel.elchi.io/v1
kind: SecurityPolicy
metadata:
name: api-graphql
spec:
defaults:
mode: block
fail_mode: fail_close
inspect_request_body: true
max_request_body_bytes: 1048576 # 1 MiB

domains:
- hosts: ["graph.example.com"]
routes:
- match:
path_prefix: "/graphql"
policy:
mode: block
engines:
graphql:
content_types: ["application/json", "application/graphql"]
paths: ["/graphql"] # optional; restrict to the GraphQL endpoint
max_depth: 10 # reject deeply-nested queries
max_aliases: 15 # alias-overload (response amplification)
max_root_fields: 20
max_total_fields: 500
max_operations: 10 # batched-array cap
max_fragment_depth: 32 # fragment-cycle bound
block_introspection: true

How it decides

Only requests that look like GraphQL are inspected: a POST with a matching content_types entry (within paths, when set), or a GET with a ?query= parameter. The document is parsed once, then checked — a zero value disables that specific check:

  • max_operations — batch arrays and multi-operation documents.
  • max_root_fields — counted through fragments, so wrapping fields in fragments can't dodge it.
  • max_depth, max_aliases, max_total_fields.
  • block_introspection__schema / __type queries.

A document that fails to parse blocks with graphql.parse_error. All blocks are severity Medium / 403.

Always-on backstops (cannot be disabled — a 0 falls back to the default): max_fragment_depth (default 32, the fragment-spread recursion bound) and max_complexity (default 100000, a per-operation node-visit budget) — the hard guard against a fragment "bomb", a tiny query whose fragments fan out exponentially. Exceeding the node budget blocks graphql.complexity immediately.

Envoy prerequisites

The engine runs at the body phase, so the policy must enable body inspection: set inspect_request_body: true and a max_request_body_bytes cap (see body inspection). No fingerprint or source-IP wiring is required. General setup: Envoy wiring.

Verify

A shallow, ordinary query passes:

curl -i http://graph.example.com/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ products { id name } }"}'
# HTTP/1.1 200 OK

An introspection query is blocked:

curl -i http://graph.example.com/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ __schema { types { name } } }"}'
# HTTP/1.1 403 Forbidden

A deeply nested query (depth > 10 in the example) is also blocked — and the same query moved to GET is caught too:

curl -i "http://graph.example.com/graphql?query=%7B%20__schema%20%7B%20types%20%7B%20name%20%7D%20%7D%20%7D"
# HTTP/1.1 403 Forbidden

Gotchas

  • Never drop the GET-over-?query= path if you customize targeting — attackers relocate deep queries to GET the moment only POST is guarded.
  • An unparseable-as-JSON body passes through — this engine only judges requests it can recognize as GraphQL. It is a DoS guard, not a positive-security gate; pair with OpenAPI or Coraza for that.
  • The two backstops are deliberately un-disable-able: setting max_fragment_depth or max_complexity to 0 restores the default rather than turning the check off.
  • Fields you leave at 0 are individually off — but at least one shape limit (or block_introspection) must be configured or the engine is rejected at load.