Persisted queries for secure headless GraphQL endpoints

Persisted queries 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:

flowchart TD
  subgraph Build["Build time (CI)"]
    Parse["Parse .graphql files"] --> Norm["Normalize AST"]
    Norm --> Hash["Compute SHA-256 digests"]
    Hash --> Sync["Sync manifest to registry"]
  end
  Sync --> Registry["CMS query registry"]
  subgraph Runtime["Runtime"]
    ClientReq["Client sends hash (GET)"] --> GW["Gateway"]
    GW -->|"lookup hash"| Registry
    Registry -->|"match"| Exec["Execute pre-validated operation"]
    Registry -.->|"no match"| Reject["Reject request"]
    Exec --> CDN["CDN-cacheable response"]
  end

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.

YAML
# 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.

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

TypeScript
// 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:

  1. Pin graphql and codegen dependencies to exact versions in package.json.
  2. Run a pre-flight normalization pass that strips comments, sorts selection sets, and standardizes indentation.
  3. 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.

Conclusion

Persisted queries trade some flexibility for a hardened, cacheable endpoint: validation shifts to build time, responses cache deterministically, and tenant registries stay isolated. The cost is disciplined CI integration, exact dependency pinning, and webhook-driven schema sync — worth it once a headless deployment scales.