Testland
Browse all skills & agents

graphql-complexity-limit-tester

The GraphQL attack-surface / hardening skill: crafts over-limit depth and complexity queries then asserts rejection before execution, verifying that graphql-depth-limit, graphql-cost-analysis, and graphql-armor (max-depth / cost-limit / max-tokens plugins) are actually enforced and not just configured; carries the introspection attack-surface catalog (what __schema exposes, per-framework disable controls, hideSchemaDetailsFromClientErrors) in references/introspection.md and the persisted-query allowlisting strategies (Apollo APQ protocol, auto-register vs strict-allowlist vs hybrid modes) in references/persisted-queries.md. Use when auditing a GraphQL service for DoS or schema-disclosure exposure, hardening a production deployment, or adding tests that prove the limits in CI.

Install with skills.sh (any agent)

npx skills add testland/qa --skill graphql-complexity-limit-tester
View source

graphql-complexity-limit-tester

Overview

The introspection attack-surface catalog (references/introspection.md) names query-depth limiting and query-cost limiting as key DoS mitigations, but nothing in that catalog executes a test. This skill closes that gap: it authors tests that send an over-limit query and assert a validation error is returned before any resolver runs. The strongest request-layer mitigation - persisted-query allowlisting - is cataloged in references/persisted-queries.md.

Three library families are covered:

Differentiation vs. apollo-server-tests: that skill covers resolver correctness + production-config gates (introspection, APQ, hideSchemaDetails). This skill is scoped exclusively to depth/complexity DoS tests - over-limit query construction, validation-layer rejection assertion, and the cross-library matrix.

Hard stop: no limit configured

If the server under test has no depth or complexity limit configured at all (no depthLimit/costAnalysis validation rule, no ApolloArmor/EnvelopArmorPlugin installed), halt immediately:

HALT: no depth/complexity limit configured.
      Tests would pass vacuously - no enforcement exists to verify.
      Install graphql-depth-limit, graphql-cost-analysis, or
      @escape.tech/graphql-armor first, then re-run this skill.

Do not write tests that assert on a server with no limit - they will produce false positives.

Step 1 - Install

Choose the library that matches the project.

# graphql-depth-limit (express-graphql / Apollo standalone rule)
npm install --save-dev graphql-depth-limit

# graphql-cost-analysis (Apollo standalone rule)
npm install --save-dev graphql-cost-analysis

# graphql-armor (Apollo or Envelop/Yoga - bundles all plugins)
npm install --save @escape.tech/graphql-armor

Step 2 - Identify the configured limit

Read the server setup to find the active limit value before crafting queries. Common locations:

LibraryWhere the limit lives
graphql-depth-limitdepthLimit(N) in validationRules array
graphql-cost-analysiscostAnalysis({ maximumCost: N }) in validationRules
graphql-armor max-deptharmor.protect() or new ApolloArmor({ maxDepth: { n: N } })
graphql-armor cost-limitnew ApolloArmor({ costLimit: { maxCost: N } })

If the limit is not explicit, use the library default: n = 6 for graphql-armor max-depth (per escape.tech/graphql-armor/docs/plugins/max-depth (opens in new window)), maxCost = 5000 for graphql-armor cost-limit (per escape.tech/graphql-armor/docs/plugins/cost-limit (opens in new window)).

Step 3 - Craft over-limit queries

Depth query

Build a query whose nesting depth is configuredLimit + 1. If the schema is User { friends: [User] } and the depth limit is 5:

query DepthBust {
  user {          # depth 1
    friends {     # depth 2
      friends {   # depth 3
        friends { # depth 4
          friends {  # depth 5
            friends { id }  # depth 6 -> over limit
          }
        }
      }
    }
  }
}

For schemas without recursive types, chain any nested relationship until the depth exceeds the limit.

Cost/complexity query

For graphql-cost-analysis, assign costs via the schema directive @cost(complexity: N) or via costMap. To construct an over-limit query without schema changes, use a fan-out pattern whose calculated cost exceeds maximumCost. Per github.com/pa-bru/graphql-cost-analysis (opens in new window), defaultCost applies to each field when no explicit cost is set; repeat high-cost fields until the sum exceeds the threshold:

query CostBust {
  users { id name email createdAt updatedAt roles permissions profile
    settings { notifications theme language timezone } }
}

For graphql-armor cost-limit, the default costs are: objectCost = 2, scalarCost = 1, depthCostFactor = 1.5 (per escape.tech/graphql-armor/docs/plugins/cost-limit (opens in new window)). A query with cost above maxCost = 5000 can be crafted by stacking scalar fields at multiple nesting levels.

Max-tokens query

For graphql-armor max-tokens, the default token limit is n = 1000 (per escape.tech/graphql-armor/docs/plugins/max-tokens (opens in new window)). Tokens include field names, arguments, braces, and directives. A query with more than 1000 tokens can be constructed by repeating field selections:

query TokenBust {
  users {
    f1 f2 f3 f4 f5 f6 f7 f8 f9 f10
    # ... repeat until token count > configured limit
  }
}

Step 4 - Write the tests

graphql-depth-limit with Apollo Server

Per the apollo-server-tests skill, use executeOperation for in-process validation. The depthLimit(n) rule is passed as a validationRules option (per npm registry description of graphql-depth-limit).

import { ApolloServer } from '@apollo/server';
import depthLimit from 'graphql-depth-limit';
import { typeDefs, resolvers } from './schema';

const DEPTH_LIMIT = 5;

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(DEPTH_LIMIT)],
});

test('rejects query exceeding depth limit', async () => {
  const overDepthQuery = `
    query DepthBust {
      user { friends { friends { friends { friends { friends { id } } } } } }
    }
  `;
  const resp = await server.executeOperation({ query: overDepthQuery });
  if (resp.body.kind !== 'single') throw new Error('expected single');

  // Validation errors are returned in errors[], not thrown
  expect(resp.body.singleResult.errors).toBeDefined();
  expect(resp.body.singleResult.data).toBeUndefined();
});

test('accepts query within depth limit', async () => {
  const safeQuery = `
    query SafeDepth {
      user { friends { id } }
    }
  `;
  const resp = await server.executeOperation({ query: safeQuery });
  if (resp.body.kind !== 'single') throw new Error('expected single');
  expect(resp.body.singleResult.errors).toBeUndefined();
});

graphql-cost-analysis with Apollo Server

Per github.com/pa-bru/graphql-cost-analysis (opens in new window), costAnalysis plugs into validationRules alongside any other rules:

import { ApolloServer } from '@apollo/server';
import costAnalysis from 'graphql-cost-analysis';

const MAX_COST = 100;

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    costAnalysis({
      maximumCost: MAX_COST,
      defaultCost: 1,
      variables: {},
    }),
  ],
});

test('rejects query exceeding cost limit', async () => {
  // Each field costs defaultCost=1; repeat fields to exceed MAX_COST
  const fields = Array.from({ length: MAX_COST + 1 }, (_, i) => `field${i}`).join('\n    ');
  const overCostQuery = `query CostBust { users { ${fields} } }`;

  const resp = await server.executeOperation({ query: overCostQuery });
  if (resp.body.kind !== 'single') throw new Error('expected single');
  expect(resp.body.singleResult.errors).toBeDefined();
});

The createError(maximumCost, cost) option (per github.com/pa-bru/graphql-cost-analysis (opens in new window)) lets you assert on a custom error message if the project overrides the default error format.

graphql-armor (Apollo) - depth + cost + tokens

Per escape.tech/graphql-armor/docs/getting-started (opens in new window), ApolloArmor spreads protection options into the server constructor:

import { ApolloServer } from '@apollo/server';
import { ApolloArmor } from '@escape.tech/graphql-armor';

const armor = new ApolloArmor({
  maxDepth: { n: 4 },         // override default 6
  costLimit: { maxCost: 200 }, // override default 5000
  maxTokens: { n: 50 },       // override default 1000
});

const server = new ApolloServer({
  typeDefs,
  resolvers,
  ...armor.protect(),
});

test('graphql-armor rejects over-depth query', async () => {
  const query = `{ a { b { c { d { e { id } } } } } }`; // depth 6 > limit 4
  const resp = await server.executeOperation({ query });
  if (resp.body.kind !== 'single') throw new Error('expected single');
  expect(resp.body.singleResult.errors).toBeDefined();
  // With exposeLimits: true (default), error includes limit detail
  // With exposeLimits: false, message is 'Query validation error.'
  // Per escape.tech/graphql-armor/docs/plugins/max-depth
});

test('graphql-armor rejects over-cost query', async () => {
  // objectCost=2, scalarCost=1, depthCostFactor=1.5 (defaults per
  // escape.tech/graphql-armor/docs/plugins/cost-limit)
  // Stack fields so calculated cost > maxCost=200
  const query = `{ users { id name email createdAt updatedAt
    profile { bio avatar roles permissions settings { a b c d e } } } }`;
  const resp = await server.executeOperation({ query });
  if (resp.body.kind !== 'single') throw new Error('expected single');
  expect(resp.body.singleResult.errors).toBeDefined();
});

test('graphql-armor rejects over-token query', async () => {
  // n=50 tokens; build a query with more than 50 tokens
  const fields = Array.from({ length: 60 }, (_, i) => `f${i}`).join(' ');
  const query = `{ users { ${fields} } }`;
  const resp = await server.executeOperation({ query });
  if (resp.body.kind !== 'single') throw new Error('expected single');
  expect(resp.body.singleResult.errors).toBeDefined();
});

graphql-armor (Envelop / GraphQL Yoga)

Per escape.tech/graphql-armor/docs/getting-started (opens in new window):

import { envelop } from '@envelop/core';
import { EnvelopArmorPlugin } from '@escape.tech/graphql-armor';

const getEnveloped = envelop({
  plugins: [
    EnvelopArmorPlugin({
      maxDepth: { n: 4 },
      costLimit: { maxCost: 200 },
      maxTokens: { n: 50 },
    }),
  ],
});

Test via the Yoga HTTP layer using supertest (same pattern as apollo-server-tests).

Step 5 - Assert rejection happens before execution

Confirm limits are enforced at validation, not resolver time. One way: instrument a resolver with a side-effect counter and assert it was never called on an over-limit query:

let resolverCallCount = 0;

const server = new ApolloServer({
  typeDefs,
  resolvers: {
    Query: {
      users: () => {
        resolverCallCount++;
        return [];
      },
    },
  },
  validationRules: [depthLimit(3)],
});

test('resolver never called on over-limit query', async () => {
  resolverCallCount = 0;
  const resp = await server.executeOperation({
    query: '{ users { friends { friends { friends { id } } } } }',
  });
  if (resp.body.kind !== 'single') throw new Error('expected single');
  expect(resp.body.singleResult.errors).toBeDefined();
  expect(resolverCallCount).toBe(0); // validation short-circuits execution
});

Running

npm test                                    # jest / vitest discover *.test.ts
npx jest --testPathPattern complexity -t "limit"

Run against the production configuration. Tests that pass in NODE_ENV=test but fail in NODE_ENV=production (or vice versa) signal a configuration drift problem. See the CI note in apollo-server-tests.

Anti-patterns

Anti-patternWhy it failsFix
Writing depth tests without checking the actual configured limitQuery may be under-limit; test passes vacuouslyRead validationRules / ApolloArmor config first
Testing at depth = limit (not limit+1)Boundary is ambiguous; limit is exclusive or inclusive depending on libraryUse limit+1 to be unambiguous
Asserting errors[0].message string exactlyError text includes dynamic limit values; brittleAssert errors is defined; check extensions.code if set
Skipping the resolver-call assertionA misconfigured rule may reach resolvers silentlyInstrument resolvers to confirm validation short-circuits
Using depthLimit and costAnalysis together without verifying precedenceFirst rule to reject wins; a low depth limit may mask cost-limit testsTest each rule independently with the other absent
Assuming graphql-armor defaults when the project overrides themWrong threshold = vacuous passAlways read the actual ApolloArmor(...) / EnvelopArmorPlugin(...) call

Limitations

  • Schema-dependent query construction. Over-limit queries require real field names from the schema under test. This skill provides pattern templates - adapt them to the actual type graph.
  • graphql-depth-limit does not cover cost/fan-out attacks. Depth 2 with 1000 siblings is not blocked. Pair with a cost rule.
  • graphql-cost-analysis is not actively maintained. Check the project's dependency health before adopting; @escape.tech/graphql-armor cost-limit is the actively maintained alternative.
  • max-tokens counts tokens in the document AST, not resolver calls. It prevents parsing overhead but not algorithmic fan-out in resolvers. Pair with cost-limit for full coverage.
  • executeOperation tests do not cover HTTP-level rate limiting. Network-layer limits (nginx, API gateway) need HTTP integration tests.

References

GraphQL introspection attack surface

View source (opens in new window)

GraphQL introspection attack surface

Pure-reference catalog of GraphQL introspection as a production attack surface and the controls available to mitigate it. Per Apollo Server docs (apollographql.com/docs/apollo-server/api/apollo-server (opens in new window)): "Introspection enables important development tools... However, this capability also allows attackers to explore your API structure."

Consumed by the per-framework GraphQL testing skills; the host SKILL.md authors the enforcement tests.

When to use

  • Designing the production-deployment posture for a GraphQL server.
  • Auditing an existing deployment - is introspection disabled, and is the test suite proving it?
  • Writing tests that gate the production introspection setting.
  • PR review where someone proposes enabling introspection in production for "debugging."

What introspection exposes

Per the GraphQL spec, the __schema and __type queries return:

FieldReveals
__schema.typesEvery type defined (including internal)
__schema.queryType / mutationType / subscriptionTypeOperation roots
__schema.directivesCustom directives + arguments
__type(name: "X").fieldsEvery field on type X - names, types, deprecation
__type.fields.argsArgument names, types, default values
__type.descriptionSchema docstrings (often have internal context)

A single query: query { __schema { types { name fields { name type { name } } } } } returns the entire API surface in JSON. An attacker uses this to:

  1. Enumerate every operation (often inferring auth gaps from missing Mutation field-level checks).
  2. Map type relationships → infer the data model.
  3. Find deprecated fields (often kept for backwards compat, often less hardened).
  4. Find internal-looking types/fields (AdminUser, _internal, __legacy) → high-value targets.

Production controls

Disable introspection

Per apollographql.com/docs/apollo-server/api/apollo-server (opens in new window):

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: false,
});

Apollo defaults to introspection: true except when NODE_ENV === 'production'. Explicit is safer:

introspection: process.env.NODE_ENV !== 'production'

Hide "did you mean" suggestions

Apollo Server also recommends hideSchemaDetailsFromClientErrors: true - without it, a typo ({ usre { id } }) returns "Did you mean 'user'?", leaking field names even with introspection disabled.

Per-framework controls

FrameworkDisable introspection
Apollo Serverintrospection: false constructor option
GraphQL YogamaskedErrors: { ... } + use @graphql-yoga/plugin-disable-introspection (opens in new window)
Mercurius (Fastify)graphiql: false + routes: false (or per-route)
HasuraHASURA_GRAPHQL_DISABLE_INTROSPECTION_PUBLIC_API=true
Pothos (schema builder)Configure the underlying server (Yoga/Apollo)

Mitigations beyond disabling

Disabling introspection is the start, not the end. A determined attacker can still field-fuzz with educated guesses. Additional controls:

Query-depth limiting

Reject queries with depth > N. Prevents the "recursive friendship-graph fan-out" DoS:

query { user { friends { friends { friends { friends { ... } } } } } }

Use graphql-depth-limit (Apollo) or @envelop/depth-limit (Yoga). Typical limit: 5-7 for client- facing schemas; higher for trusted internal.

Query-cost analysis

Assign a cost to each field; reject queries with total cost > N. Catches breadth-attacks where depth is low but the multiplicative fan-out (e.g., posts { comments { likes { user { profile } } } }) is enormous.

graphql-cost-analysis (Apollo) or @envelop/operation-complexity (Yoga).

Field-level authorisation

Per-field @auth directives or resolver-level checks. Even with introspection on, attackers can't read what they can't access. Belt-and-suspenders with disabling.

Persisted-query allowlisting

The strongest mitigation. Only pre-registered query hashes execute; ad-hoc queries (including introspection probes) are rejected at the request layer. See persisted-queries.md (opens in new window).

Testable behaviours

Each control creates a test:

ControlTest
Introspection disabled in prodPOST /graphql { __schema { types { name } } } → 400 / "introspection is not allowed"
hideSchemaDetailsFromClientErrorsTypo query → no "did you mean" in response
Query-depth limitConstruct depth-N+1 query → error "exceeds maximum operation depth"
Query-cost limitConstruct high-cost query → error "exceeds maximum operation cost"
Persisted-query allowlistSubmit non-allowlisted hash → PERSISTED_QUERY_NOT_FOUND (per persisted-queries.md (opens in new window))

These tests must run against the production configuration - running them against NODE_ENV=test may give false positives if test config differs from prod.

Anti-patterns

Anti-patternWhy it failsFix
introspection: true in production for debuggingPermanent attack surface; "temporary" debug becomes defaultUse BFF + protected admin schema for debug; never main API
Trusting Apollo's NODE_ENV defaultOne mis-set env var = leakExplicit process.env.NODE_ENV !== 'production' or hard false
Disable introspection but allow GraphiQL UIGraphiQL queries introspectDisable both
Disable introspection without testingConfig drift; deploys re-enable silentlyProduction smoke test asserts 400 on __schema query
Field-level auth on Query but not MutationMutations often the more sensitiveAudit every field, both directions
No depth / cost limit, introspection disabledBrute-force fuzzing still worksLayer the mitigations
Persisted queries enabled but PERSISTED_QUERY_NOT_FOUND retries succeedEffectively no allowlistSee persisted-queries.md (opens in new window) Mode 3
Production schema includes description with internal contextSchema disclosure even via partial introspectionSanitise descriptions; treat as user-visible

Limitations

  • Disabling introspection doesn't hide the schema entirely. Field guessing still works. Treat introspection-disable as defence-in-depth, not the whole defence.
  • Persisted queries break ad-hoc clients. Internal admin tools that build queries on the fly need a separate endpoint.
  • Codegen / Schema-first dev workflows. Need a way to fetch the schema in CI without exposing it publicly: schema-push to an internal artifact registry (Apollo Studio, Hive).
  • GraphiQL on staging. A common leak - staging schema often mirrors prod; staging URL discovered → schema disclosed.
  • __typename always works. Even with introspection off, the meta-field __typename returns the type name in responses - some inference still possible.

References

Persisted-query strategies

View source (opens in new window)

Persisted-query strategies

Pure-reference catalog of GraphQL persisted-query strategies. Per Apollo Server docs (apollographql.com/docs/apollo-server/performance/apq (opens in new window)): "A persisted query is a query string that's cached on the server side, along with its unique identifier (always its SHA-256 hash)."

Two motivations, often conflated:

  1. Performance - smaller payloads, GET-cacheable on CDNs.
  2. Security - allowlist enforcement; only registered queries execute (which mitigates the introspection-attack surface per introspection.md (opens in new window)).

The configuration mode determines which motivation dominates.

When to use

  • Designing the GraphQL request layer for a new production service.
  • Auditing an existing APQ configuration - is it allowlist-mode or auto-register-mode?
  • Investigating CDN-cache hit-rate or payload-size issues.
  • PR review of changes to the persisted-query setup.

The hash + extensions protocol

Per Apollo Server docs, the request format:

GET /graphql
  ?extensions={"persistedQuery":{"version":1,"sha256Hash":"<HEX>"}}
  &variables={"id":"u1"}

Or as POST:

{
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"
    }
  },
  "variables": { "id": "u1" }
}

Apollo's example: { __typename } hashes to ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38.

The three modes

Mode 1 - APQ auto-register (default, permissive)

const server = new ApolloServer({
  typeDefs,
  resolvers,
  persistedQueries: {
    ttl: 900,   // 15 minutes
  },
});

Flow on first request with new hash:

  1. Client sends { extensions: { persistedQuery: { sha256Hash: H } } }.
  2. Server cache miss → responds PERSISTED_QUERY_NOT_FOUND.
  3. Client retries with both the hash + the full query.
  4. Server hashes the query, verifies it matches H, caches with TTL, executes.
  5. Subsequent calls with H succeed (no query string sent).

What this gets you:

  • Smaller subsequent payloads (just the hash).
  • CDN-cacheable GETs (per Apollo: "When configured with useGETForHashedQueries: true, queries become GET requests that CDNs can cache").
  • No allowlist enforcement - any client can register any query.

Best for: performance optimisation; not a security control.

Mode 2 - Persisted-query-only (allowlist, strict)

const server = new ApolloServer({
  typeDefs,
  resolvers,
  persistedQueries: false,  // Apollo's auto-APQ off
});

// Externally: build a manifest of allowed hashes during CI,
// load into a `pre-registered` store, reject anything not in it.

Architecturally: the persisted-queries store is pre-populated during the build/deploy (via codegen of the client app), not by client requests. Any unknown hash → reject (not register-then-execute).

Flow:

  1. Build pipeline extracts queries from the client app, hashes each, writes to manifest.json.
  2. Server boot loads manifest.json into the persisted-query store.
  3. Client sends { extensions: { persistedQuery: { sha256Hash: H } } }.
  4. Server cache lookup. If hit → execute. If miss → 400 Bad Request, no auto-register.
  5. Any request with query field set (no hash) → also rejected in strict mode.

What this gets you:

  • Allowlist enforcement: only build-time-known queries execute.
  • Strong defence against introspection probes + crafted attack queries.
  • Smaller payloads + CDN-cacheable.

Best for: internet-facing production APIs with a known client app (web / mobile).

Tradeoff: breaks ad-hoc clients (admin tools, GraphiQL, internal Postman collections). Mitigate via a separate admin-only endpoint, or a long-lived admin token that bypasses.

Mode 3 - Hybrid (allowlist prod, auto-register dev)

const server = new ApolloServer({
  typeDefs,
  resolvers,
  persistedQueries:
    process.env.NODE_ENV === 'production'
      ? false                      // Mode 2 setup elsewhere
      : { ttl: 900 },              // Mode 1 for dev
});

Best of both: dev gets the iteration speed of auto-register; prod gets the allowlist. Risk: config drift - staging may use dev settings.

Implementation patterns

Client side (Apollo Client)

Per Apollo docs:

import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';

const link = createPersistedQueryLink({
  sha256,
  useGETForHashedQueries: true,    // CDN-cacheable GETs
}).concat(new HttpLink({ uri: '/graphql' }));

const client = new ApolloClient({ link, cache: new InMemoryCache() });

useGETForHashedQueries is the CDN-cache lever - without it, POST requests aren't cacheable by most CDNs.

Generating a manifest for strict mode

npx graphql-codegen --config codegen.yml
# Outputs operation strings + hashes to manifest.json

# Per Apollo, with @apollo/persisted-query-lists for build-time
# generation:
npm run extract-queries -- --output manifest.json

Then in CI / deploy: upload manifest.json as a JSON artifact; server boot reads it.

Disable APQ entirely (no persisted queries)

Per Apollo: persistedQueries: false.

Per-framework support

FrameworkPersisted-query support
Apollo ServerBuilt-in: persistedQueries: { ttl } or false
GraphQL Yoga@graphql-yoga/plugin-persisted-operations (per yoga docs)
Mercuriuscache: { ... } + custom resolver
HasuraAllow lists via query_collections + add_to_allowlist mutations
PothosConfigure via underlying server

Testable behaviours

ModeTest
Mode 1First request with unknown hash → 200 with PERSISTED_QUERY_NOT_FOUND extension; retry succeeds
Mode 2Request with unregistered hash → 400 with no registration; subsequent requests still 400
Mode 2Request with raw query field → 400 (strict mode rejects unhashed)
Mode 3Same as Mode 1 in dev; same as Mode 2 in prod (test against both NODE_ENV)
All modesManifest reload preserves existing hashes (no flush during deploy)
All modesTTL expiration in Mode 1 → fallback to retry flow (must not 500)

These tests prove the chosen mode is actually in effect.

Anti-patterns

Anti-patternWhy it failsFix
Mode 1 with introspection disabled, expecting allowlistAuto-register accepts any query; allowlist isn't realMode 2 (build-time manifest) is the only true allowlist
Strict mode without a manifest workflowFirst deploy = total service outage (no queries allowed)Manifest extraction in client build, upload before server deploy
Manifest as a single file in imageSchema changes require full image rebuildExternalise to S3 / config service; reload on signal
GraphiQL exposed alongside strict APQGraphiQL queries fail; team disables APQ to "debug"Separate admin endpoint for GraphiQL, with explicit auth
TTL too short (60s)Cold-cache misses → high PERSISTED_QUERY_NOT_FOUND ratettl: 900 (15min) or longer for stable queries
TTL too long (forever)Old query versions stick around; security policy staleRefresh on deploy; ttl 1-7 days max
No test asserting PERSISTED_QUERY_NOT_FOUND flow worksClient retry logic silently breaks; perf regression unnoticedE2E test: drop cache, send only-hash, assert retry flow
CDN caches POSTs of hashed queries by mistakeTenant-id in variables → cross-tenant cache contaminationuseGETForHashedQueries: true + per-tenant cache key derivation

Limitations

  • APQ is not encryption. The hash + the query both leak via packet capture once registered.
  • Server-side cache. A restart wipes Mode 1 caches; clients re-register on first request. Strict mode caches are file- loaded so durable.
  • CDN cache key. Variables aren't part of the hash; the CDN key needs to include variables (and Authorization for tenant-scoped data) or you get cross-tenant cache hits.
  • Doesn't replace introspection-disable. A determined attacker with introspection enabled can still construct queries and submit them; persisted-query strict mode rejects.
  • Mutation safety. Allowlisting mutations is high-value; often misconfigured because mutations are "rare" and tested manually.

References

Related skills

apollo-server-tests

Wraps Apollo Server testing patterns: `server.executeOperation()` (in-process, no HTTP), `supertest` against an ephemeral-port HTTP server (port 0), context injection via the `contextValue` second-argument, and assertion patterns for response shape + errors. Includes the production-config gates testable through this skill - introspection-disabled, persisted-query mode, hideSchemaDetailsFromClientErrors. Use when writing tests for an Apollo Server v4+ GraphQL service.

graphql-n-plus-one-remediation

Detects and fixes the GraphQL N+1 pattern: scans a repo, PR diff, or schema type for list-returning resolvers (grep-driven detection workflow), traces the resolver tree to locate the fan-out (one parent query returns N rows, then a child field resolver fires once per row), classifies every child field resolver as safe or N+1 risk, and applies one of three fixes: per-request DataLoader batching, eager projection in the parent resolver, or selection-set-aware prefetch. Use when reviewing a PR that adds or changes a list-returning resolver, when a connection-pool exhaustion or slow-query alert traces back to GraphQL traffic, or when a resolver trace shows a child field resolved once per parent row.

graphql-subscription-test-author

Authors GraphQL subscription resolver test suites over graphql-ws (WebSocket) and graphql-sse (Server-Sent Events) transports: subscribe to event streams via the async-iterator API, assert emitted data shape and sequence, verify connection lifecycle and protocol close codes, and validate auth-on-connect (connectionParams / authenticate callback) plus resolver-level pubsub trigger logic. Use for real-time subscription operations; not for queries or mutations - for those use apollo-server-tests or graphql-yoga-tests (Mercurius in its references).

graphql-yoga-tests

Tests a GraphQL Yoga server (the-guild.dev runtime) with `yoga.fetch()` for in-process, no-network request simulation of queries and mutations, `@graphql-tools/executor-http` for subscription and incremental-delivery (streaming) tests, auth-header pass-through, and production-config gates for disabled introspection and persisted operations; also carries the Mercurius (Fastify GraphQL plugin) in-process `app.inject()` testing patterns in references/mercurius.md. Use to test a GraphQL Yoga or Mercurius server, write query, mutation, or subscription tests, or check production plugin config; for other runtimes use apollo-server-tests or hasura-tests instead, not this skill.

hasura-tests

Wraps Hasura GraphQL Engine testing patterns: docker-compose test instance, the metadata API for declarative schema/permission setup, x-hasura-role and x-hasura-user-id session headers for role-based permission tests, the v1/graphql endpoint via curl/HTTPie/native HTTP clients, and the role-by-table-by-operation permission-matrix pattern. Use for a metadata-driven Hasura engine where row-level permissions dominate; for a code-first server runtime harness use graphql-yoga-tests (Mercurius in its references) or apollo-server-tests instead, not this skill.