Persisted Queries in GraphQL: The Hardening Step Most Teams Skip
Persisted queries let a GraphQL server execute only pre-approved operations by hash. Here's how the APQ exchange works, when to move to a strict allow-list, and how to verify it.
14 Aug 2026, 16:59 UTC

Your GraphQL endpoint accepts arbitrary query text from anyone who can reach it. That's the feature and the problem at the same time: a single crafted query with deep nesting or an expensive field fan-out can burn CPU and database connections, and rate limiting by request count doesn't help when one request does the damage of a thousand.
Persisted queries are the standard answer. Instead of sending full query text, the client sends a hash of a pre-approved operation, and the server only executes operations it already knows about. This post covers how the mechanism works, the real engineering decision you'll face (automatic vs. strict allow-list), and how to verify it's actually doing what you think.
How the hash-only exchange works
With Automatic Persisted Queries (APQ), supported by Apollo Server and several other implementations, the client computes a SHA-256 hash of the query string and sends only that:
POST /graphql\n{\n \"extensions\": {\n \"persistedQuery\": {\n \"version\": 1,\n \"sha256Hash\": \"ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38\"\n }\n }\n}On the first request the server doesn't know the hash, so it responds with a PersistedQueryNotFound error. The client then retries with both the hash and the full query text. The server executes it and registers the hash. Every subsequent request sends only the hash — the server never sees the query text again.
Two benefits fall out of this. First, payloads shrink: a 2 KB query document becomes a 64-character hash, which matters on mobile and high-traffic paths. Second, because the hash is stable and the request is effectively idempotent, read operations can be sent as GET requests and cached by a CDN keyed on the hash — something you can't safely do with arbitrary POST bodies.
The real decision: APQ vs. a build-time allow-list
APQ is convenient but it is not a security boundary. Any client can register any query at runtime by doing the retry dance, so APQ alone doesn't stop expensive arbitrary queries — it mainly saves bandwidth and enables caching.
The stronger pattern is a strict allow-list built at deploy time:
- Your client codebase is the source of truth for all operations. Code generation tooling (e.g., GraphQL Code Generator) emits typed operations along with their hashes as part of the client build.
- That manifest of hashes is shipped to the server alongside the deployment — as a file, a database table, or a registry service.
- The server is configured to reject any hash it doesn't recognize, with no runtime registration fallback.
Now arbitrary queries are structurally impossible: if an operation isn't in the manifest, it doesn't run. The cost is operational. You need a pipeline that keeps the client build and server allow-list in sync, and a deployment ordering story (register the new hashes before rolling out clients that use them, or you'll get a wave of rejected requests).
A reasonable middle path many teams take: start with APQ for the bandwidth and caching wins, then move to a strict allow-list once the client build pipeline matures.
Verifying it actually works
Don't trust the config flag — exercise the protocol. Run these against a staging instance of your server (no special permissions needed beyond network access):
- Cache miss: send a hash-only request for a brand-new hash. Expect a
PersistedQueryNotFounderror, not an execution result. - Registration: retry with hash plus full query. Expect a normal response.
- Cache hit: send the same hash alone again. Expect the same result with no query text in the request. Check server logs to confirm only the hash arrived.
- Rejection (allow-list mode): send an unregistered operation with full text. Expect rejection, while a registered hash succeeds.
- GET caching: if your server supports GET-based persisted queries, issue one twice through your CDN and confirm the second response is served from cache (check
ageor your CDN's cache-status header).
One debugging gotcha to plan for: with hash-only requests, your logs show hashes, not queries. Keep the hash-to-operation mapping from your build manifest accessible to whoever is on call, or a 3 a.m. incident becomes a guessing game.
What persisted queries don't fix
This is the part that gets skipped. A registered operation can still be expensive — nothing stops a pre-approved query from requesting a deeply nested graph or triggering N+1 resolver calls. Persisted queries are one layer, not the strategy:
- Query complexity and depth limits still matter, because they bound the cost of even your registered operations.
- DataLoader-style batching still matters, because persisted queries do nothing for resolver efficiency.
- Behavior varies by implementation. APQ protocol details, GET support, and allow-list configuration differ across Apollo Server, GraphQL Yoga, graphql-java, Hot Chocolate, and across versions. Read your server's docs for the exact protocol version and confirm client and server agree before rolling out.
Where to start
If you do one thing this week: enable APQ on your server (it's typically a small config change), run the five verification checks above against staging, and confirm the hash-only round trip works with your actual client. That gets you smaller payloads and CDN-cacheable reads immediately. Then put \"strict allow-list from codegen manifest\" on the roadmap — it's the step that turns persisted queries from a performance tweak into an actual hardening control.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.