Persisted Queries for Secure Headless GraphQL Endpoints
Within Advanced GraphQL Federation Patterns, persisted queries harden the public edge of the graph. They replace raw GraphQL query strings with SHA-256 operation hashes resolved against a pre-validated registry, so the CMS only executes operations registered at build time. This shrinks payloads, makes responses deterministically cacheable at the CDN, and shuts the door on arbitrary query execution at the edge.
An open GraphQL endpoint exposes introspection and unbounded traversal. Without an allowlist, attackers exploit alias flooding, deep recursion, and batch abuse to trigger denial-of-service or extract schema metadata. Persisted queries move validation to build time: the gateway runs only operations that were registered and audited, rejecting anything else.
Why allowlisting beats runtime validation
Runtime GraphQL parses the AST, resolves types, and computes complexity on every request — measurable latency plus a wide attack surface. Persisted queries enforce an implicit allowlist: only operations extracted during the frontend build can execute. The gateway rejects any raw query string or unrecognized hash, introspection is disabled at the transport layer, and schema evolution is decoupled from client execution. When evaluating platforms under Headless CMS Architecture & Platform Selection, native query-registration support is a real procurement criterion — some ship a registry endpoint, others need custom middleware or an external routing layer.
Build-time registration pipeline
A resilient implementation rests on deterministic extraction and hashing. In CI, the build parses every .graphql file, normalizes the AST, computes SHA-256 digests, and syncs the hash-to-query mapping to the CMS registry.
This flow shows registration at build time and the hash-only path the gateway enforces at runtime:
Normalization is non-negotiable. Strip or standardize whitespace, field ordering, and inline comments before hashing — otherwise formatting differences between dev and prod produce divergent hashes and 404 cache misses.
# codegen.yml
overwrite: true
schema: "https://cms.example.com/graphql"
documents: "src/**/*.graphql"
generates:
src/generated/operations.json:
plugins:
- "persisted-operations"
config:
useTypeImports: true
hashAlgorithm: "sha256"
normalize: true
The extraction step emits a JSON manifest mapping each hash to its query, uploaded to the CMS over an authenticated endpoint before the frontend deploys.
// scripts/upload-registry.ts
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
interface OperationRegistry {
[hash: string]: string;
}
async function syncRegistry(manifestPath: string, cmsEndpoint: string, token: string) {
const raw = readFileSync(manifestPath, "utf-8");
const registry: OperationRegistry = JSON.parse(raw);
// Validate payload before transmission
const hashes = Object.keys(registry);
if (hashes.length === 0) throw new Error("No operations extracted for registration.");
const response = await fetch(`${cmsEndpoint}/api/v1/query-registry`, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
"X-Registry-Version": process.env.CI_COMMIT_SHA || "dev",
},
body: JSON.stringify({ operations: registry }),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Registry sync failed: ${err}`);
}
console.log(`✅ Registered ${hashes.length} operations successfully.`);
}
On the client, configure the transport to swap queries for their hashes automatically. Apollo Client does this with a dedicated link.
// src/graphql/client.ts
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";
const httpLink = new HttpLink({ uri: "https://cms.example.com/graphql" });
const persistedLink = createPersistedQueryLink({
sha256,
useGETForHashedQueries: true, // Enables CDN caching
disable: process.env.NODE_ENV === "development",
});
export const client = new ApolloClient({
link: persistedLink.concat(httpLink),
cache: new InMemoryCache({
typePolicies: {
Query: { fields: { _persisted: { merge: false } } }
}
}),
defaultOptions: {
watchQuery: { fetchPolicy: "cache-first" },
},
});
Edge resolution and deterministic caching
Once registered, the gateway matches the hash query parameter against its key-value store. Because the operation is pre-validated, it skips AST parsing, complexity analysis, and depth limiting — latency drops and the response becomes fully edge-cacheable. Sending hashed queries via GET unlocks standard HTTP caching: CDNs key responses by hash, content type, and authorization scope, eliminating the cache fragmentation that dynamic query strings cause. See the Apollo Persisted Queries documentation for transport details.
Operational resilience
Hash mismatches are the dominant production failure, usually from inconsistent GraphQL parser versions across a monorepo — a minor bump in graphql or @graphql-codegen/cli can change AST traversal order and produce different hashes for identical syntax.
Fixes:
- Pin
graphqland codegen dependencies to exact versions inpackage.json. - Run a pre-flight normalization pass that strips comments, sorts selection sets, and standardizes indentation.
- Allow raw queries in development but enforce hash-only routing in staging and production.
Schema changes are the other operational hazard: a modified type can invalidate registered queries. Subscribe to schema.changed or content-type.updated webhooks, rebuild the registry, version the manifest with semantic tags, and deploy the new frontend bundle alongside the schema.
Multi-tenant isolation and governance
On shared CMS instances, different tenants can submit identical hashes for divergent operations — a cross-tenant leak risk. Prefix registry keys with tenant identifiers (tenant:{id}:query:{hash}) and resolve tenant context from JWT claims or subdomain routing before the lookup.
Extend RBAC to the registration pipeline: only authorized CI service accounts may upload operations, and reject registrations that bypass governance or target restricted content types. This mirrors the boundary enforcement in Advanced GraphQL Federation Patterns. Under strict compliance, keep an audit log of every registered operation with commit SHA, author, and timestamp for security review and rollback.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| Mode | trusted documents (safelist) in staging and production | Blocks arbitrary operations. |
| Transport | GET with hash and variables | CDN can cache by URL. |
| Hash | SHA-256 over the normalized document | Stable across formatting differences. |
| Registry key | tenant:{id}:{hash} on shared instances |
Prevents cross-tenant collisions. |
| Dev mode | raw queries allowed | Developer ergonomics without weakening production. |
Gotchas & Edge Cases
- APQ is not an allow-list. Apollo’s
createPersistedQueryLinkimplements automatic persisted queries: an unknown hash triggers a retry with the full query, which the server then registers. That improves caching but blocks nothing. For security, register operations at build time and configure the server to reject unknown ids, for example with Apollo’s persisted query manifest support or the router’s safelisting. - Variables in URLs. GET requests carry variables in the query string, where they appear in logs and have length limits. Keep sensitive values out of variables for public operations, and fall back to POST with the hash for large variable sets.
- Deploy ordering. A new frontend that references hashes the server does not know yet fails immediately. Upload the manifest before deploying the frontend, and keep old hashes registered until the previous frontend is fully retired.
- Authorization still applies. A persisted operation can still request data the user may not see. Keep field-level authorization in subgraphs; the safelist restricts shapes, not permissions.
Worked Example
A media company’s public GraphQL endpoint in front of its CMS received a burst of deeply nested queries from a scraper, which pushed CMS usage over its monthly quota in two days. Switching the web and mobile clients to trusted documents, registered in CI from the operations actually used in the codebase, took a week. After the change the gateway rejected every unregistered operation, the scraper’s requests failed at the edge without touching the CMS, and because all public operations now travelled as GET requests with hashes, the CDN answered more than 90 percent of listing requests, which cut CMS API usage by an order of magnitude on top of the security win.
Rollout Checklist
- Extract operations from client code in CI and generate a manifest of normalized documents and hashes.
- Upload the manifest to the registry before deploying the clients that use it.
- Send hashed operations over GET so the CDN can cache them.
- Enable safelisting in staging first, log rejections for a week, then enforce in production.
- Keep hashes for older client versions registered until those versions are retired.
Persisted queries also combine naturally with the cost limits described in rate limiting and query complexity: because every operation is known in advance, its cost can be computed once at registration and stored with the hash, so the router enforces limits without scoring anything at request time.
Frequently Asked Questions
Do persisted queries replace query complexity limits?
For public clients, largely yes, because every operation was reviewed at build time. Keep limits for internal or partner clients that are allowed to send ad-hoc queries.
How do mobile apps with old versions in the wild fit in?
Keep every hash that any supported app version uses registered, versioned by app release. Retire hashes only when those versions are no longer supported.
Can a headless CMS’s own GraphQL API use persisted queries?
Some vendors support persisted or cached queries natively; others do not. Where they do not, put your own gateway in front of the CMS and apply persisted queries there.
How do I debug a rejected operation in production?
Log the operation id and client version for every rejection. Most rejections come from a client deployed before its manifest was uploaded, which the version field makes obvious.
Do persisted queries help server-rendered pages too?
Yes. Server-side fetches benefit from the smaller requests and CDN caching of GET operations, and safelisting protects the endpoint regardless of which client calls it.