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
94%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
This plugin was archived by the owner on Oct 11, 2026
Reason: Tile archived: Retired from testland/qa (merged into another skill or removed).
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:
introspection-attack-surface-reference).The configuration mode determines which motivation dominates. Consumed by per-framework test authors.
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.
const server = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: {
ttl: 900, // 15 minutes
},
});Flow on first request with new hash:
{ extensions: { persistedQuery: { sha256Hash: H } } }.PERSISTED_QUERY_NOT_FOUND.query.H, caches with
TTL, executes.H succeed (no query string sent).What this gets you:
useGETForHashedQueries: true, queries become GET requests
that CDNs can cache").Best for: performance optimisation; not a security control.
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:
manifest.json.manifest.json into the persisted-query
store.{ extensions: { persistedQuery: { sha256Hash: H } } }.query field set (no hash) → also rejected
in strict mode.What this gets you:
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.
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.
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.
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.jsonThen in CI / deploy: upload manifest.json as a JSON artifact;
server boot reads it.
Per Apollo: persistedQueries: false.
| Framework | Persisted-query support |
|---|---|
| Apollo Server | Built-in: persistedQueries: { ttl } or false |
| GraphQL Yoga | @graphql-yoga/plugin-persisted-operations (per yoga docs) |
| Mercurius | cache: { ... } + custom resolver |
| Hasura | Allow lists via query_collections + add_to_allowlist mutations |
| Pothos | Configure via underlying server |
| Mode | Test |
|---|---|
| Mode 1 | First request with unknown hash → 200 with PERSISTED_QUERY_NOT_FOUND extension; retry succeeds |
| Mode 2 | Request with unregistered hash → 400 with no registration; subsequent requests still 400 |
| Mode 2 | Request with raw query field → 400 (strict mode rejects unhashed) |
| Mode 3 | Same as Mode 1 in dev; same as Mode 2 in prod (test against both NODE_ENV) |
| All modes | Manifest reload preserves existing hashes (no flush during deploy) |
| All modes | TTL expiration in Mode 1 → fallback to retry flow (must not 500) |
These tests prove the chosen mode is actually in effect.
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Mode 1 with introspection disabled, expecting allowlist | Auto-register accepts any query; allowlist isn't real | Mode 2 (build-time manifest) is the only true allowlist |
| Strict mode without a manifest workflow | First deploy = total service outage (no queries allowed) | Manifest extraction in client build, upload before server deploy |
| Manifest as a single file in image | Schema changes require full image rebuild | Externalise to S3 / config service; reload on signal |
| GraphiQL exposed alongside strict APQ | GraphiQL 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 rate | ttl: 900 (15min) or longer for stable queries |
| TTL too long (forever) | Old query versions stick around; security policy stale | Refresh on deploy; ttl 1-7 days max |
No test asserting PERSISTED_QUERY_NOT_FOUND flow works | Client retry logic silently breaks; perf regression unnoticed | E2E test: drop cache, send only-hash, assert retry flow |
| CDN caches POSTs of hashed queries by mistake | Tenant-id in variables → cross-tenant cache contamination | useGETForHashedQueries: true + per-tenant cache key derivation |
variables (and Authorization for
tenant-scoped data) or you get cross-tenant cache hits.introspection-attack-surface-reference.apollo-server-tests,
graphql-yoga-tests,
mercurius-tests,
pothos-builder-tests.