Security and Performance in GraphQL
Persisted Queries solve two problems at once: they reduce the amount of data transferred and close a security gap that is often overlooked in public GraphQL APIs. Anyone who accepts arbitrary queries hands attackers a free pass. Anyone who only allows known queries gains control, without sacrificing flexibility entirely.
Table of Contents
- 1. The problem with arbitrary queries in public APIs
- 2. What Persisted Queries are and how they work
- 3. Automatic Persisted Queries (APQ): the two-step protocol
- 4. Query whitelisting: only allow known queries
- 5. CDN caching with Persisted Queries
- 6. Common implementation mistakes
- 7. Persisted Queries in Magento GraphQL
- 8. With and without Persisted Queries compared
- 9. Summary
- 10. At a glance
- 11. FAQ
1. The problem with arbitrary queries in public APIs
An open GraphQL API that accepts arbitrary queries is fundamentally risky from a security perspective. An attacker can send queries with extreme depth, a huge number of fields, or circular fragments that block the server for minutes, even when query depth limits and complexity limits are in place. These parameters can never cover every attack vector, because the schema itself defines the complexity, and not every expensive field is automatically recognized as such.
On top of that there is a performance problem: every GraphQL query is transmitted as text over HTTP, validated, parsed, and then executed. For a production app serving thousands of requests per minute, the query text itself is an unnecessary burden. In addition, GET requests with a dynamic query body can barely be cached at the CDN level, because the body cannot serve as a cache key. Persisted Queries solve all three problems at once: they reduce the payload, allow GET caching, and enable whitelisting.
2. What Persisted Queries are and how they work
A Persisted Query is a pre-registered GraphQL query that is retrieved via a unique identifier, typically a SHA-256 hash of the query text. Instead of sending the full query text with every request, the client only sends the hash. The server looks up the matching query in its store, executes it, and returns the result. This considerably reduces the payload for complex queries, because a SHA-256 hash is always 64 characters long, while the query text can span hundreds or thousands of characters.
The store for Persisted Queries can be implemented in different ways: in-memory cache, Redis, database-backed, or an external query registry such as GraphQL Hive. What matters is that the store stays consistent between frontend build time (where queries get registered) and runtime (where queries get resolved). Deployments therefore need to happen in a way that updates the store before the new frontend code goes live, otherwise the server encounters queries with an unknown hash and returns an error.
# This query is registered at build time with its SHA-256 hash
# Client only sends the hash at runtime, not the full query text
query ProductListQuery($search: String!, $pageSize: Int!) {
products(search: $search, pageSize: $pageSize) {
total_count
page_info {
current_page
page_size
total_pages
}
items {
sku
name
url_key
price_range {
minimum_price {
final_price { value currency }
}
}
}
}
}
# SHA-256 hash: 7a3f1c2e9b4d6f8a0e2c4b1d3e5f7a9b2c4d6e8f0a1b3c5d7e9f1a2b4c6d8e0
3. Automatic Persisted Queries (APQ): the two-step protocol
Automatic Persisted Queries (APQ) is a protocol developed by Apollo that automates the registration step. On the first request the client sends the SHA-256 hash of the query to the server. If the server already knows the query, it responds directly. If not, it returns a PersistedQueryNotFound error. The client then sends a second request with the hash and the full query text. The server registers the query in its cache and responds. From the next request onward, the hash alone is enough again.
APQ is a pragmatic compromise: it requires no upfront registration and no build-time infrastructure, yet it still delivers the performance benefits for every request after the first one. For security purposes, APQ alone is not sufficient, because the server still accepts arbitrary queries, it just caches the query texts more efficiently. Real whitelisting needs a separate mechanism that does not let the hash store grow with unknown hashes, but only allows pre-registered queries.
# APQ: First request, client sends only the hash
# POST /graphql
# { "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "7a3f1c..." } } }
# Server responds: { "errors": [{ "message": "PersistedQueryNotFound" }] }
# APQ: Second request, client sends hash + full query
# POST /graphql
# {
# "query": "query ProductListQuery(...) { ... }",
# "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "7a3f1c..." } }
# }
# Server registers query and responds with data
# APQ: All subsequent requests, only the hash is needed
# GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"7a3f1c..."}}
# CDN can now cache this GET request by URL
query AnyRegisteredQuery {
products(search: "jacket", pageSize: 10) {
total_count
items { sku name }
}
}
4. Query whitelisting: only allow known queries
Query whitelisting is the stricter variant: the server maintains a fixed list of known queries (as hashes or full texts), and only these get executed. Unknown hashes and arbitrary query texts are rejected. This closes the attack vector completely: an attacker cannot construct a custom query and submit it, because the server will not execute it. The list is typically generated during the frontend build and rolled into the server store.
The downside is obvious: whitelisting means every new query in the frontend requires a deploy step. For production applications with multiple frontends and frequent releases, that is a real coordination effort. In practice, whitelisting is therefore mostly used for public, unauthenticated APIs, such as catalog queries, homepage data, and similar. For internal admin tools and authenticated areas, the security gain is smaller and the effort is hardly justified.
5. CDN caching with Persisted Queries
One of the most valuable practical benefits of Persisted Queries lies in CDN caching. Standard GraphQL requests are transmitted via POST, and POST requests are not cacheable per the HTTP specification. Persisted Queries with APQ make it possible to transmit queries via GET, the hash and optional variables end up in the URL's query string, and GET responses can easily be cached by CDNs such as Fastly, Cloudflare, or Varnish.
This is especially interesting for publicly accessible, non-user-specific data: product lists, category trees, homepage content, and similar can be served at the CDN edge without putting load on the origin server. The cache key generation does need to be built carefully, however, so that variables, locale, and other relevant parameters are part of the cache key. Broken cache keys mean that users get the wrong data from the CDN cache, a common and hard-to-debug problem.
# GET request with persisted query, CDN-cacheable
# GET /graphql?operationName=HomepageData&extensions={"persistedQuery":{"version":1,"sha256Hash":"abc123"}}&variables={"locale":"de"}
# Cache-Control: public, max-age=300, stale-while-revalidate=60
# Query registered at build time, only its hash is sent at runtime
query HomepageData($locale: String!) {
categoryList(filters: { parent_id: { eq: "2" } }) {
id
name
url_key
children_count
}
cmsPage(identifier: "homepage") {
title
content
meta_description
}
}
6. Common implementation mistakes
The most common mistake with Persisted Queries is deployment timing. If the frontend is deployed with new query hashes before the server has the current hash store, every request fails with an error. The fix: always update the hash store before the frontend deploy, and never deploy both at the same time. A transition phase, in which both stores are active, is often necessary for zero-downtime deployments.
Another mistake is forgetting variables in the cache key. A Persisted Query with the hash ID for a product list query returns different results depending on the value of the search variable. CDN caching without variables in the cache key means the first user who searches for "jacket" ends up delivering the jacket search results to the second user who searches for "shirt". That is not just wrong, it is embarrassing. Variables must always be part of the cache key, either in the query string or derived into a cache header.
7. Persisted Queries in Magento GraphQL
Magento does not support Persisted Queries natively out of the box, but the framework it uses allows an implementation via a plugin mechanism or a separate middleware layer. In practice, APQ is often implemented at the level of a GraphQL gateway (e.g. Apollo Router, GraphQL Mesh) in front of Magento, not directly inside Magento itself. That has advantages: the gateway can manage the APQ cache, and Magento always sees complete queries, regardless of whether the client uses APQ or not.
For CDN caching, Magento offers its own caching infrastructure via X-Magento-Cache-Id and Varnish integration. This can be combined with Persisted Queries by deriving the cache key from hash plus variables plus customer group context. Unauthenticated product list queries can then be cached at the CDN edge and served directly, without putting load on Magento. The combination of Persisted Queries, CDN caching, and Magento's own cache tags is one of the most effective performance patterns for Magento headless.
8. With and without Persisted Queries compared
The table below shows the concrete differences between a standard GraphQL API and an API using Persisted Queries across several dimensions.
| Dimension | Without Persisted Queries | With Persisted Queries | Note |
|---|---|---|---|
| Request payload | Full query text (100 to 5000 characters) | SHA-256 hash (64 characters) | Up to 98% smaller for large queries |
| CDN caching | Not possible (POST) | Possible via GET | Variables must be part of the cache key |
| Query security | Arbitrary queries allowed | Only whitelisted queries | Only with strict whitelisting, not APQ alone |
| Deploy complexity | Simple | Timing between frontend and store | Store must be current before frontend deploy |
| Development flexibility | Full flexibility | Every new query needs registration | Whitelisting restricts ad hoc queries |
9. Summary
Persisted Queries are not an optional nice-to-have for production GraphQL APIs, they are one of the most effective measures for security and performance. APQ delivers immediately measurable performance improvements through smaller payloads and enables CDN caching via GET requests. Whitelisting closes the attack vector of arbitrary query complexity completely and makes the API substantially more robust for public use cases. The effort mostly lies in build-time integration and deployment timing.
For Magento headless the rule is: the combination of Persisted Queries, CDN caching, and Magento's own cache tags is one of the most effective patterns for taking load off the origin server and minimizing response times for end users. The implementation requires a gateway layer in front of Magento, but it is achievable with tools such as Apollo Router, GraphQL Mesh, or even a simple Nginx layer. The long-term performance gains clearly outweigh the one-time implementation effort.
Persisted Queries, the essentials at a glance
APQ protocol
Two steps: first send only the hash, on failure send hash plus query text. After that, only the hash. No build-time setup needed, but no real whitelisting either.
Whitelisting
Only pre-registered queries get executed. Closes the attack vector of arbitrary queries completely. Requires coordination between frontend build and server deploy.
CDN caching
GET requests with the hash in the query string are CDN-cacheable. Variables and locale must be part of the cache key, otherwise other users get the wrong data.
Magento recommendation
Gateway layer (Apollo Router, GraphQL Mesh) in front of Magento for the APQ cache. CDN caching of unauthenticated queries combined with Magento cache tags.