How automatic persisted queries (APQ) work and what they don't protect
With automatic persisted queries (APQ), the client sends a SHA-256 hash of the query text instead of the full document. The hash usually goes in a persistedQuery extension on the request. If the server has that hash in its cache, it executes the stored query. If it doesn't, it returns a not-found error, and the client retries with the full query plus the hash. The server recomputes the hash, confirms it matches, stores the pair, and executes. After that, every request for the operation is a small hash, which keeps request bodies short. It also makes GET requests practical, because the URL stays within length limits and CDNs can cache responses.
The cache is populated by whoever sends the query, so any client can register any query, including one written by an attacker against your full schema. APQ does not limit which operations can run. It only changes how they are transmitted. Treat it as a bandwidth and caching optimization, not a security control.
Failure modes
-
Cold caches and evictions. After a deploy, restart, or LRU eviction, every operation costs an extra round trip until clients re-register it. On high-latency mobile links this delay is visible to users.
-
Shared vs. per-instance stores. A shared store such as a networked key-value cache adds a dependency and a network hop to every request. Per-instance memory caches avoid that hop, but behind a load balancer a client can miss on each new instance it reaches.
-
Registration over GET. The server must verify that each hash matches its query. Without that check, an attacker can bind a legitimate hash to a different document, poisoning the cache for every client that sends that hash. Accepting registration over GET adds further risks:
- Cross-site requests can trigger registrations.
- Anyone can fill the store with junk entries.
- Full query text ends up in URLs and access logs.
Restrict registration to POST.
import { createHash } from "node:crypto";
type ApqRequest = {
method: "GET" | "POST";
query?: string;
extensions?: { persistedQuery?: { version: number; sha256Hash: string } };
};
// Per-instance cache: behind a load balancer each instance misses independently.
// Replace with a shared store to avoid repeated misses, at the cost of a network hop.
const store = new Map<string, string>();
export function resolveQuery(req: ApqRequest): { query: string } | { error: string } {
const pq = req.extensions?.persistedQuery;
if (!pq) return req.query ? { query: req.query } : { error: "Missing query" };
if (pq.version !== 1) return { error: "Unsupported persisted query version" };
// Hash-only request: a hit runs the stored text, a miss tells the client to retry.
if (!req.query) {
const stored = store.get(pq.sha256Hash);
return stored ? { query: stored } : { error: "PersistedQueryNotFound" };
}
// Registration writes to the cache, so refuse it over GET (cross-site requests, crawlers).
if (req.method === "GET") return { error: "Persisted query registration requires POST" };
// Recompute the hash; never trust the client's hash-to-query pairing (cache poisoning).
const actual = createHash("sha256").update(req.query, "utf8").digest("hex");
if (actual !== pq.sha256Hash) return { error: "Provided sha256Hash does not match query" };
// No allowlist check here: any client can register any valid query.
// In production, bound this cache (e.g. LRU) so registrations cannot exhaust memory.
store.set(actual, req.query);
return { query: req.query };
}
A minimal server-side APQ resolver that returns PersistedQueryNotFound on a miss, recomputes the SHA-256 before storing a query, and rejects registration over GET, while still letting any client register any query.
How trusted documents work: a build-time operation allowlist
Trusted documents reverse the APQ model. The server does not learn operations from whoever sends them at runtime. Every operation is known before the client ships, and only the build pipeline can register one. The pattern is also called persisted operations or an operation allowlist.
At build time, a tool scans the client source for GraphQL documents, such as tagged templates or .graphql files. It gives each one a stable identifier, usually a hash of the document text, and writes a manifest that maps each identifier to its document. CI publishes that manifest to an operation store, which can be a database, a key-value store, or a file deployed with the server. In production the client sends only the identifier and its variables.
client build CI pipeline operation store GraphQL server
┌────────────┐ ┌──────────────────┐ ┌────────────────┐ ┌──────────────────┐
│ app source │ ───▶ │ extract ops │ │ id → document │ │ request: id+vars │
│ (.graphql, │ │ hash → manifest │ ──▶ │ (append-only) │ ◀── │ lookup id │
│ gql tags) │ │ publish manifest │ │ │ │ miss → reject │
└────────────┘ └──────────────────┘ └────────────────┘ │ raw text → reject│
└──────────────────┘
When the server receives a request, it looks up the identifier in the store. If the identifier is unknown, the server rejects the request. A miss does not prompt the client to send the full query, as it would under APQ. The server can also refuse raw query text entirely.
This changes what an attacker can do. Without trusted documents, an attacker can send any query the schema allows. With them, the attacker can only replay operations your team wrote and reviewed.
Two operational rules follow from the design:
- Publish before you deploy. The manifest must reach the store before the client release that depends on it goes live.
- Keep old entries. Older client versions stay in use after a release, so you cannot delete their operations from the store as soon as a new version ships.
BUILD TIME (CI is the only writer)
┌──────────────────┐ ┌──────────────────────────┐
│ client source │ │ CI: extract operations │
│ .graphql files, │───▶│ hash each document │
│ gql tagged tmpls │ │ write manifest id → doc │
└──────────────────┘ └────────────┬─────────────┘
│ publish manifest
▼
┌──────────────────────────┐
│ operation store │
│ id → document │
│ append-only; written │
│ only by the CI pipeline │
└──────────────────────────┘
▲
│ lookup id
RUNTIME (production) │
┌──────────────────┐ ┌────────────┴─────────────┐
│ shipped client │───▶│ GraphQL server │
│ sends id + vars │ │ hit → execute document │
│ no query text │◀───│ miss → reject, no retry │
└──────────────────┘ │ raw query text → reject │
└──────────────────────────┘
Trusted documents: CI extracts and hashes operations into a manifest published to an append-only store, and at runtime the server executes only IDs it finds there, rejecting unknown IDs and raw query text.
APQ vs trusted documents: security, performance and caching compared
The core difference is who decides what can run. With APQ, any client can register any query by sending its full text alongside the hash, so an attacker can still submit deeply nested queries, alias-multiplied fields or introspection, and get them cached for later reuse. With trusted documents, the server rejects any hash it doesn't already know. The set of runnable operations is fixed at build time and can be reviewed like any other code. That is a smaller attack surface than depth or complexity limits provide, because those limits only restrict a query's shape, not whether it is allowed at all.
| Concern | APQ | Trusted documents |
|---|---|---|
| Arbitrary queries accepted | Yes | No |
| Introspection / abusive shapes | Must be blocked separately | Only if an allowlisted operation contains them |
| Payload size | Hash instead of full text | Hash instead of full text |
| GET + CDN/HTTP caching | Supported | Supported |
Both approaches benefit from GET caching, and both share its pitfalls:
- Cache keys. The key must include the operation hash, the variables and anything else that changes the response, such as locale or auth context. Personalized responses should never land in a shared cache.
- Leaking variables. Variables in a URL end up in access logs, browser history and
Refererheaders. Operations that carry sensitive variables should stay on POST.
Depth and complexity limits, cost analysis and rate limiting remain useful. They are the main defense for APQ or open APIs, and they complement an allowlist. Trusted documents do not replace authorization, rate limiting, cost analysis or field-level access checks in resolvers. An allowlisted operation can still be called by the wrong user, too often or with expensive variables.
Who should use which: a decision guide
The choice comes down to one question: do you know every client that will ever send a query to this endpoint?
| Scenario | Recommended approach | Why |
|---|---|---|
| Public API, unknown consumers | APQ alone | Smaller requests and GET-based CDN caching without restricting what anyone can send |
| API used only by your own web and mobile apps | Trusted documents | Every legitimate operation exists in your repositories, so anything else can be rejected |
| First-party apps plus an open developer ecosystem | Both, on separate paths | First-party traffic gets the allowlist, third parties get an open endpoint with tighter limits |
When to avoid trusted documents
An allowlist makes the API unusable for anyone outside your build pipeline. A partner integrating with you, a customer writing a script, or a developer exploring the schema in an IDE cannot get their operations registered. You have two options. You can serve those consumers from a separate open endpoint with stricter query cost and depth limits, a lower rate limit, and narrower scopes. Or you can accept that the allowlist doesn't protect that traffic, and rely on the controls covered in the previous section.
Mobile apps strengthen the case for trusted documents, because old builds stay installed for a long time. You need to keep their operations registered until those versions stop receiving traffic.
Tooling varies widely by stack. Some servers and clients support APQ, operation manifests, or both. Others need plugins or custom middleware. Check what your own server and client libraries support before you commit to a design.
Implementing trusted documents in your CI/CD pipeline
…committing to a pipeline design. The core flow has three steps: extract every operation from the client source in CI, write a manifest that maps each operation's ID (usually a hash of the normalized document) to its full text, and publish that manifest to the server's operation store. The example below shows a minimal manifest and a server lookup. At request time, the server reads the ID, fetches the matching document from the store, and rejects the request if the ID is unknown. It never parses a query string sent by the client.
Release coupling
The manifest and the client binary must ship as a pair. If a client reaches users before its operations are registered, every new or changed query fails in production. Make the publish step a hard gate in the pipeline. The client build or deploy job should start only after the store confirms the write. If publishing fails, stop the release instead of retrying in the background. Make publishing idempotent so that re-running a pipeline doesn't create conflicts.
Retention and deprecation
Publishing is additive. Deleting IDs breaks clients that are still in use. Web clients usually turn over within days. Mobile apps can stay installed for months or years, and some users never update. Record which IDs each client version uses and log per-ID request counts on the server. Base deprecation on that data, for example by retiring an ID once its traffic falls below a threshold you've agreed with product owners. Pair this with a minimum-supported-version policy so users of very old app versions are prompted to upgrade instead of getting silent failures.
// Manifest produced in CI: operation ID (hash of normalized document) -> full text.
// Published to the operation store before the client build is allowed to start.
const manifest = {
version: 1,
clientName: "ios-app",
clientVersion: "4.12.0",
operations: {
"sha256:3f9a1c0e...placeholder": "query GetOrder($id: ID!) { order(id: $id) { id status total } }",
"sha256:b72d44e1...placeholder": "mutation CancelOrder($id: ID!) { cancelOrder(id: $id) { id status } }",
},
};
// Server side: the operation store is any key-value lookup (database, cache, file).
interface OperationStore {
get(id: string): Promise<string | undefined>;
}
interface UsageLog {
increment(id: string, clientVersion: string): void;
}
async function resolveOperation(
body: { documentId?: string; query?: string },
clientVersion: string,
store: OperationStore,
usage: UsageLog,
): Promise<string> {
// Reject raw query text so only registered documents ever execute.
if (body.query !== undefined || !body.documentId) {
throw new Error("Only registered operation IDs are accepted");
}
const document = await store.get(body.documentId);
if (document === undefined) {
// Unknown ID: client shipped before its manifest was published, or the ID was retired.
throw new Error(`Unknown operation ID: ${body.documentId}`);
}
// Per-ID, per-version counts are the data behind deprecation decisions.
usage.increment(body.documentId, clientVersion);
return document; // Hand the stored text to the normal parse/validate/execute path.
}
A minimal trusted-documents manifest generated in CI and a server lookup that executes only registered operation IDs, rejects unknown IDs or raw query text, and records per-ID usage to inform deprecation.
Rolling out an allowlist on an existing API and operating it
Phase the rollout
Start in log-only mode: the server resolves every request against the allowlist, records misses with client name and version, and still executes them. After a full release cycle, query those logs to see what share of traffic would have been rejected. Misses usually come from forgotten clients, internal scripts, or builds that skipped the registration step. Fix those before enforcing. Then enforce for first-party clients, identified by credentials or client headers. Route third-party consumers to a separate endpoint that accepts arbitrary documents under depth, complexity, and rate limits. That keeps the allowlist from becoming a negotiation with partners.
Where the store lives
| Store | Update path | Main risk |
|---|---|---|
| Database | CI writes rows on deploy | Lookup latency, connection limits |
| Key-value store | CI writes keys; servers read on miss | Network dependency per cold ID |
| Config bundle | Shipped with server release | Clients ahead of server get rejected |
Whichever you choose, servers should hold the full set in an in-process cache. Refresh it on a timer or on a change notification, not per request. A store outage then only blocks new IDs. Decide explicitly how a cold start behaves when the store is down. Failing closed protects the security model. Failing open turns an outage into a bypass. Most teams fail closed for first-party traffic.
Observing it
Emit a counter for rejections tagged by client and version, and alert on rate changes against a baseline rather than on absolute counts. A spike right after a mobile release usually means a registration step failed. Use the operation ID as the metric label and trace attribute. It is stable and has bounded cardinality, unlike raw query text.
type Mode = "log" | "enforce";
interface Store { loadAll(): Promise<Map<string, string>>; } // id -> document
interface Metrics { increment(name: string, tags: Record<string, string>): void; }
interface Req { id: string; query?: string; client: string; version: string; }
// First-party endpoint only. Third-party traffic goes to a separate endpoint with limits.
export class Allowlist {
private docs: Map<string, string> | null = null; // null = never loaded since boot
constructor(private store: Store, private metrics: Metrics, private mode: Mode) {}
// Load the full set into memory and refresh on a timer, never per request.
// A failed refresh keeps the last good copy, so a store outage only blocks new IDs.
start(intervalMs = 30_000): void {
const refresh = async () => {
try { this.docs = await this.store.loadAll(); }
catch { this.metrics.increment("allowlist.refresh_failed", {}); }
};
void refresh();
setInterval(refresh, intervalMs).unref();
}
// Returns the document to execute, or null to reject the request.
resolve(req: Req): string | null {
const doc = this.docs?.get(req.id);
if (doc) return doc; // caller sets operation_id as a span attribute and metric label
// Unknown IDs are attacker-controlled, so tag misses by client and version only.
// That keeps label cardinality bounded.
this.metrics.increment("allowlist.miss", {
client: req.client,
version: req.version,
mode: this.mode,
cold_start: String(this.docs === null),
});
// Log-only phase: record the miss but still run the raw document the client sent.
if (this.mode === "log") return req.query ?? null;
// Enforce phase: fail closed, including a cold start while the store is down.
return null;
}
}
A minimal allowlist resolver for first-party traffic. It holds the full store in memory, refreshes it on a timer, switches between log-only and enforce modes, fails closed on a cold start, and counts misses by client and version with bounded-cardinality labels.
Developer experience, cost and engineering time
Trusted documents carry ongoing costs. The manifest grows with every release. Old mobile builds stay in use for a long time, so you can't safely delete an entry until telemetry shows zero traffic for it. Someone also has to own the registration step, its credentials and its failure modes.
The hardest cost to predict is hash drift. Client tooling may print or normalize a document one way (whitespace, field order, fragment placement), the server another, and the IDs never match. Clients only see a generic "unknown operation" error. Unless your logs record the received ID, the client name and version, and the loaded manifest version, engineers end up diffing printed documents by hand.
To reduce friction:
- Accept raw query text in local and staging environments. Developers can iterate without registering anything. Enforce the allowlist only in production.
- Commit the manifest and review its diff in pull requests. Reviewers then see new or changed operations next to the code that uses them.
- Standardize normalization. Hash once, at build time, with one implementation. The server should look IDs up, not recompute them.
The time savings come from a fixed operation set. Security review covers a known list instead of every query someone could write. Incident triage starts from an operation ID. Per-operation metrics show which queries need performance work.
Illustrative estimate (assumed numbers, not a benchmark): net hours per month = incidents × (triage hours without IDs − triage hours with IDs) − maintenance hours. Assume 4 incidents, 3 versus 1 triage hours, and 3 maintenance hours: 4 × (3 − 1) − 3 = 5 hours. This is reasoning from assumed inputs. Replace them with your own numbers.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to graphql persisted queries, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.