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.
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.
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.
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.
Related
- Securing the Federated Gateway — parent guide
- Implementing Automatic Persisted Queries in Federation — the bandwidth-focused sibling
- Rate Limiting Federated GraphQL Operations — the volume control
- Propagating JWT Claims to Subgraphs with Apollo Router — identity past the edge
- Apollo Router Configuration and Deployment — where this configuration lives