CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/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.

75

Quality

94%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files

SKILL.md

name:
persisted-query-strategy-reference
description:
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.

persisted-query-strategy-reference

Overview

Pure-reference catalog of GraphQL persisted-query strategies. Per Apollo Server docs (apollographql.com/docs/apollo-server/performance/apq): "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

SKILL.md

tile.json