Enforcing Persisted Query Safelists in Apollo Router

A safelist turns your GraphQL endpoint from “anything the schema permits” into “the operations this build ships”, and it is the single largest reduction in attack surface available to a federated graph. This page covers what the router checks, how a manifest gets published, and — most importantly — the rollout order that keeps clients working while you turn enforcement on. It sits under Securing the Federated Gateway.

When to use this pattern

  • Use it for any public or partner-facing endpoint, where you cannot vouch for the clients and a leaked credential should not become the ability to run arbitrary operations.
  • Use it for mobile clients, where operations change only at release and the bandwidth saving is a genuine bonus.
  • Skip enforcement for an internal exploratory graph. A safelist on a graph that dozens of teams query ad hoc turns every investigation into a pull request, and it will be disabled during the first incident.

Prerequisites

Three modes, not two

People think of safelisting as on or off. The router actually offers three states, and the middle one is what makes a safe rollout possible.

Off. Every request carries a full operation document. The router parses, validates, plans, and executes it. This is where most graphs start.

Persisted queries enabled, not enforced. Clients may send an identifier instead of a document, and the router resolves it from the published manifest. Clients that still send full documents continue to work unchanged. This mode gives you the bandwidth saving and, crucially, a metric: what proportion of traffic is already using identifiers.

Enforced (require_id: true). The router refuses any operation that does not arrive as a known identifier. A full document is rejected regardless of whether it is valid, and an unknown identifier is rejected before parsing.

The middle state is not a stepping stone you rush through; it is where you live for as long as it takes every client version in the wild to reach a release that uses identifiers. On mobile that can be months, and enforcing before then breaks users who cannot update.

Three states, and what each accepts With persisted queries off, only documents are accepted. Enabled but unenforced accepts both documents and identifiers. Enforced accepts only known identifiers and rejects everything else before parsing. The middle state is where a safe rollout happens state full document known identifier off accepted not understood enabled, not enforced accepted accepted enforced rejected before parsing accepted Moving from row two to row three is safe exactly when the proportion of document traffic reaches zero — which is a number you can watch rather than a date you have to guess. On mobile, that number reaches zero months after the release that changed it.

Implementation walkthrough

The manifest is produced by the build that produces the client, which is the only ordering that works: publishing afterwards means a shipped client whose operations are not yet registered.

// persisted-queries.json — emitted by the client build
{
  "format": "apollo-persisted-query-manifest",
  "version": 1,
  "operations": [
    {
      "id": "a91f6c3e8d4b2f7a1c5e9b0d3f8a6c2e4b7d1f9a3c6e8b0d2f5a7c9e1b3d6f8a",
      "name": "GetCheckout",
      "type": "query",
      "body": "query GetCheckout($id: ID!) { order(id: $id) { id total } }"
    }
  ]
}
# Publish in the same CI job that builds the client artifact, before release.
rover persisted-queries publish my-graph@current \
  --manifest ./persisted-queries.json

The router configuration is short, and the comments mark the two lines that carry all the risk:

# router.yaml
persisted_queries:
  enabled: true
  # Phase 1: leave this false while clients roll out. Setting it true is the
  # moment a client on an old build stops working.
  safelist:
    enabled: true
    require_id: false
  # Reporting even while unenforced: log what enforcement WOULD reject, so the
  # migration has a number rather than a feeling.
  log_unknown: true

Client-side, the change is a link in the chain rather than a rewrite:

import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { HttpLink, ApolloClient, InMemoryCache } from "@apollo/client";
import manifest from "./persisted-queries.json";

// Look up the id generated at build time rather than hashing at runtime —
// runtime hashing produces ids the manifest does not contain.
const idsByBody = new Map(manifest.operations.map((o) => [o.body, o.id]));

const link = createPersistedQueryLink({
  generateHash: (document) => idsByBody.get(document.loc?.source.body ?? "") ?? "",
  disable: () => false,   // never fall back to sending the document
}).concat(new HttpLink({ uri: "/graphql" }));

export const client = new ApolloClient({ link, cache: new InMemoryCache() });

Two details in that client matter. Using the build-time id rather than hashing at runtime avoids a whole class of mismatch caused by whitespace or fragment-ordering differences between what the extractor saw and what the runtime document contains. And disabling the automatic fallback is deliberate: with the fallback on, a mismatch quietly sends the full document and works fine in the unenforced phase, then fails everywhere the day you enforce.

Verification steps

Verify the three states behave as expected, in order:

# Known identifier — should succeed in every state.
curl -s localhost:4000/graphql -H 'content-type: application/json' \
  -d '{"extensions":{"persistedQuery":{"version":1,"sha256Hash":"a91f6c3e…"}},"variables":{"id":"O-1"}}'

# Unknown identifier — should be rejected in every state.
curl -s localhost:4000/graphql -H 'content-type: application/json' \
  -d '{"extensions":{"persistedQuery":{"version":1,"sha256Hash":"deadbeef"}}}'
# expect: PersistedQueryNotFound

# Full document — accepted until require_id is true, rejected after.
curl -s localhost:4000/graphql -H 'content-type: application/json' \
  -d '{"query":"{ __typename }"}'

Then watch the metric that actually decides readiness: the share of requests arriving with an identifier. Enforcement is safe when that reaches one hundred percent and stays there across a full release cycle of your slowest client. Reading it per client version rather than in aggregate is what tells you which build is holding you back.

Enforce when the document line reaches zero The proportion of traffic sending full documents falls as client releases adopt identifiers. Enforcement is safe once that proportion reaches zero and stays there across a release cycle, not on a chosen date. Traffic still sending full documents, by week documents enforcing here breaks real users safe to enforce week 0 week 8 week 16 The trailing edge is old mobile builds that will never update. Decide whether to break them or to force an upgrade — but decide, rather than letting the tail defer enforcement forever.

Common mistakes & gotchas

Publishing the manifest after the client ships. The client is live and its operations are unknown, so every request fails the moment enforcement is on. Publish in the build that produces the client, not in a later step.

Leaving the automatic fallback enabled. A hash mismatch silently sends the full document, which works perfectly until enforcement and then fails everywhere at once. Disable the fallback so mismatches surface in the unenforced phase, where they are cheap.

Hashing at runtime instead of using the build-time id. Whitespace, fragment ordering and printer differences all change a runtime hash. Use the id the extractor generated.

Four failures and the symptom each produces Late manifest publication, an enabled fallback, runtime hashing, and an unrotated manifest each produce a distinct symptom, and each has a different fix. The symptom tells you which mistake it was symptom cause and fix all clients fail at once manifest never published — publish it in the client build only one client fails that build predates identifiers — wait, or force an upgrade fine before enforcing, broken after the fallback was masking a hash mismatch — disable it intermittent, one operation only runtime hashing over a document that varies — use build ids

Frequently Asked Questions

Does a safelist replace authorization?

No. It constrains which operations exist, not who may run them. A safelisted operation is still subject to every authorization check in the schema, and a caller with no permissions can still send it and be denied. The two are complementary: safelisting shrinks the space of possible requests, authorization decides what each request returns.

What about operations used only by internal tools?

Give them their own graph variant or their own router deployment rather than adding them to the public manifest. A public safelist containing admin operations is a published list of your internal capabilities, and it invites exactly the probing the safelist was meant to prevent.

How do we handle a client we cannot update?

Decide deliberately rather than deferring. Options are keeping a separate unenforced endpoint for that client on a short deadline, forcing an upgrade with a version gate, or accepting that the enforcement date breaks it. The one option that does not work is waiting for the tail to disappear on its own, because it will not.

Does safelisting help performance?

Yes, measurably. A rejected operation costs a hash lookup instead of a parse and a plan, and accepted operations have a bounded, warm query-plan cache because the set of possible plans is finite. It is the rare security control that makes the system faster rather than slower.

Can operations be added without a client release?

They can be published at any time — the manifest is independent of the client artifact. But an operation nobody ships cannot be used, so publishing early is only useful as preparation. The ordering that matters is that publication happens before, never after, the client that uses it goes live.

Does a safelist interfere with schema evolution?

Not in the direction people fear. Adding fields is unaffected, because the manifest is a set of whole operations rather than a list of permitted fields. Removing a field is where the two interact usefully: the manifest is an exact inventory of every operation any live client can send, which makes “is anything still using this field?” a question you can answer by searching a file rather than by inferring from traffic samples. That is a considerably stronger signal than usage statistics, because it covers operations a client ships but has not run recently.

What about tools like GraphiQL against a safelisted endpoint?

They will not work, by design — an exploratory tool sends arbitrary documents. Point developer tooling at an unenforced internal variant instead, which is a good idea regardless: the exploratory surface and the production surface have different requirements, and a separate variant lets each have the posture it deserves.