Testland
Browse all skills & agents

persisted-query-strategy-reference

Pure-reference catalog of GraphQL Persisted Query strategies: the Apollo APQ SHA-256 hash protocol, the PersistedQueryNotFoundError retry flow, the `extensions.persistedQuery` payload, GET-vs-POST and CDN-cache implications, and the three operation modes (auto-register, strict allowlist, hybrid). Use to design or audit a server's persisted-query request layer. This strategy reference emits no tests; to author the runtime tests use apollo-server-tests, graphql-yoga-tests, or mercurius-tests, and for introspection lockdown see introspection-attack-surface-reference.

Install with skills.sh (any agent)

npx skills add testland/qa --skill persisted-query-strategy-reference
View source

persisted-query-strategy-reference

Overview

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-attack-surface-reference).

The configuration mode determines which motivation dominates. Consumed by per-framework test authors.

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-complexity-limit-tester

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. Use when auditing a GraphQL service for DoS exposure after depth or cost limits have been added as mitigations, or when adding tests that prove the limits in CI before a production deployment.

graphql-n-plus-one-remediation

Traces a GraphQL resolver tree to locate the N+1 pattern (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 a list-returning resolver is added or changed in review, 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, graphql-yoga-tests, or mercurius-tests.

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. Use to test a GraphQL Yoga server, write Yoga query, mutation, or subscription tests, or check its production plugin config; for a different runtime harness use apollo-server-tests, mercurius-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, apollo-server-tests, or mercurius-tests instead, not this skill.

introspection-attack-surface-reference

Pure-reference catalog of GraphQL introspection as an attack surface and the production-deployment controls for it. Covers what introspection exposes (every type, field, directive, deprecation, description via __schema / __type), Apollo Server's default behaviour (introspection: false when NODE_ENV=production), the `hideSchemaDetailsFromClientErrors: true` companion setting (strips 'did you mean' suggestions), Yoga / Mercurius / Hasura equivalents, query-depth + query-cost limits, persisted-query allowlisting as the strongest mitigation, and the testable behaviours each control creates. Use when designing the production-safety posture of a GraphQL server or auditing an existing deployment.

mercurius-tests

Wraps Mercurius (Fastify GraphQL plugin) testing patterns: `app.inject()` for HTTP-layer simulation without spinning up a network listener, plugin-registration setup (await app.register(mercurius, { schema, resolvers, graphiql: false })), production-config gates (graphiql: false; jit threshold; query depth limits via fastify-rate-limit + complexity), and the per-test app lifecycle (app.close() in afterEach). Use when writing tests for a Fastify + Mercurius GraphQL server.

pothos-builder-tests

Wraps Pothos GraphQL schema-builder testing patterns: testing the SchemaBuilder output (lexicographicSortSchema + printSchema for snapshot tests), testing resolvers via the standard `graphql()` function from graphql-js (no server needed), integration with Apollo Server / GraphQL Yoga (Pothos emits standard graphql-js schemas), and code-first builder unit tests. Covers the SchemaBuilder API surface (queryType, mutationType, objectType, t.field, t.arg). Use when testing a Pothos-built schema before or alongside the server-runtime tests.