query hashes instead of full text, less attack surface
Persisted queries replace the full GraphQL query text with a short hash that was registered with the server beforehand. This reduces payload size, prevents arbitrary ad hoc queries against the storefront API, and makes GraphQL requests accessible to standard CDN caching through plain GET calls.
Table of Contents
- 1. Which problem persisted queries solve
- 2. How it works: query hash instead of query text
- 3. Persisted queries in Magento: not native, but doable
- 4. Custom plugin implementation for hash resolution
- 5. Storing the query mapping in Redis
- 6. Security gains through query whitelisting
- 7. CDN caching with GET requests
- 8. Client side integration with Apollo Client
- 9. Regular queries vs. persisted queries compared
- 10. Summary
- 11. FAQ
1. Which problem persisted queries solve
A typical GraphQL query for a product page can, including all nested fields and fragments, span several kilobytes of text. On every request, this complete query string is sent over the wire, even though the text usually does not change at all between two calls of the same page. Persisted queries address exactly this: instead of the full text, the client only transmits a short, unique hash that has already registered the query with the server beforehand.
Beyond pure payload reduction, persisted queries solve a second, often underestimated problem: the attack surface of an open GraphQL API. Without restriction, any client can send arbitrary, even very expensive or deeply nested queries to the storefront API, which can lead to denial of service style load spikes. A persisted queries setup that only accepts registered hashes effectively turns the open API into a whitelist of known, vetted queries.
For high traffic Magento shops, the third advantage is often the decisive one: GraphQL requests are traditionally sent as POST requests because the query text is transmitted in the body, and POST requests are not cached by default by most CDNs and reverse proxies. Persisted queries, on the other hand, can be sent as a GET request with the hash in the URL, which enables classic HTTP caching at the CDN level, without any GraphQL specific cache logic in the CDN.
2. How it works: query hash instead of query text
The protocol established by Apollo for Automatic Persisted Queries (APQ) works in two steps. On the first call of a new query, the client initially sends only the SHA-256 hash of the query text through extensions.persistedQuery.sha256Hash. If the server does not yet know this hash, it responds with the error code PersistedQueryNotFound. The client reacts by repeating the same request, this time however with the full query text in addition to the hash. The server computes the hash from the received text, checks it against the submitted hash, and permanently stores the mapping from hash to query text.
From this point on, every further call of the same query only needs the hash, without transmitting the full text again. Since the same query text remains identical across many users and sessions, for example a shop's standard product page query, virtually all subsequent requests benefit from the reduced payload after the initial registration. This two step procedure is the core of every persisted queries implementation, regardless of the concrete backend.
3. Persisted queries in Magento: not native, but doable
Magento's GraphQL implementation does not ship persisted queries as a built in feature, unlike, for example, Apollo Server. For a Magento project, that means rebuilding the APQ protocol through a custom plugin on the GraphQL front controller. The overall flow stays identical to the Apollo standard: incoming requests are checked for extensions.persistedQuery, known hashes are resolved to the stored query text, unknown hashes trigger the PersistedQueryNotFound response.
The entry point for this implementation is the Magento\GraphQl\Controller\GraphQl controller, or rather an around plugin on it that intercepts the request before the actual query execution begins. When a hash is found, the associated query text is loaded from storage and inserted into the request, so that the rest of the Magento GraphQL stack continues working unchanged, as if the full text had been sent from the start.
<?php
declare(strict_types=1);
namespace Mironsoft\PersistedQueries\Plugin;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Webapi\Rest\Response;
use Magento\GraphQl\Controller\GraphQl;
use Mironsoft\PersistedQueries\Model\PersistedQueryStoreInterface;
/**
* Resolves persisted GraphQL queries by hash before the core dispatch logic runs.
*/
final class ResolvePersistedQuery
{
/**
* @param PersistedQueryStoreInterface $queryStore Storage for hash-to-query-text mappings
* @param RequestInterface $request Current HTTP request
*/
public function __construct(
private readonly PersistedQueryStoreInterface $queryStore,
private readonly RequestInterface $request
) {
}
/**
* Intercept the controller dispatch to resolve a persisted query hash into full query text.
*
* @param GraphQl $subject
* @param \Closure $proceed
* @param RequestInterface $request
* @return Response
*/
public function aroundDispatch(GraphQl $subject, \Closure $proceed, RequestInterface $request): Response
{
$body = json_decode((string) $request->getContent(), true) ?? [];
$hash = $body['extensions']['persistedQuery']['sha256Hash'] ?? null;
if ($hash === null) {
return $proceed($request);
}
if (empty($body['query'])) {
$storedQuery = $this->queryStore->find($hash);
if ($storedQuery === null) {
// Client must resend with full query text once
return $this->buildNotFoundResponse();
}
$body['query'] = $storedQuery;
$request->setContent(json_encode($body));
return $proceed($request);
}
if (hash('sha256', $body['query']) === $hash) {
$this->queryStore->save($hash, $body['query']);
}
return $proceed($request);
}
}
4. Custom plugin implementation for hash resolution
Beyond pure resolution, a production ready implementation must cover several edge cases. A tampered hash that does not match the query text actually sent must not be accepted, otherwise the whitelisting could be bypassed with a fake hash. Likewise, an upper limit for the size of incoming query texts must apply, so that the initial registration itself cannot be abused as an attack vector for oversized payloads.
A second important aspect concerns backward compatibility: not every client supports persisted queries, especially during a gradual migration. The plugin code should therefore pass requests without extensions.persistedQuery unchanged to normal query processing instead of rejecting them. This allows classic and persisted requests to work in parallel until the migration is completed across all frontend clients.
5. Storing the query mapping in Redis
The mapping from hash to query text needs to be available across multiple requests and, ideally, across multiple web server instances, a simple in memory cache per PHP process is not enough for this. Redis is an excellent storage backend, because Magento already runs a Redis instance for session or cache data in most production setups anyway. The key is the SHA-256 hash, the value is the full query text, with a reasonable TTL so that never used queries eventually expire automatically.
<?php
declare(strict_types=1);
namespace Mironsoft\PersistedQueries\Model;
use Magento\Framework\App\Cache\Type\FrontendPool;
use Magento\Framework\Cache\FrontendInterface;
/**
* Redis-backed storage for persisted GraphQL query hash-to-text mappings.
*/
final class RedisPersistedQueryStore implements PersistedQueryStoreInterface
{
private const CACHE_TAG = 'PERSISTED_GRAPHQL_QUERY';
private const TTL_SECONDS = 2592000; // 30 days
/**
* @param FrontendPool $cacheFrontendPool Provides access to the configured cache frontend
*/
public function __construct(
private readonly FrontendPool $cacheFrontendPool
) {
}
/**
* Find the stored query text for a given hash.
*
* @param string $hash
* @return string|null
*/
public function find(string $hash): ?string
{
$value = $this->getFrontend()->load($this->buildKey($hash));
return $value === false ? null : $value;
}
/**
* Persist the query text under its hash for future lookups.
*
* @param string $hash
* @param string $queryText
* @return void
*/
public function save(string $hash, string $queryText): void
{
$this->getFrontend()->save($queryText, $this->buildKey($hash), [self::CACHE_TAG], self::TTL_SECONDS);
}
/**
* @return FrontendInterface
*/
private function getFrontend(): FrontendInterface
{
return $this->cacheFrontendPool->get('default');
}
/**
* @param string $hash
* @return string
*/
private function buildKey(string $hash): string
{
return self::CACHE_TAG . '_' . $hash;
}
}
6. Security gains through query whitelisting
The security aspect of persisted queries goes beyond pure performance. In a hardened setup, the server exclusively accepts hashes that were registered beforehand through a controlled build or deploy process, not through ad hoc registration by arbitrary clients at runtime. This model is often called strict persisted queries: only queries that actually appear in the frontend code of the project's own team can ever be executed, any other query, no matter how harmless it looks, gets rejected.
For Magento shops with a publicly reachable storefront API, this model significantly reduces the risk of attackers deliberately constructing expensive, deeply nested queries to overload the database or the cache layer. Combined with classic query depth limiting and rate limiting, this creates a layered security concept in which persisted queries form the first and most effective line of defense against arbitrary query construction.
7. CDN caching with GET requests
As soon as a query is only transmitted as a short hash, the entire request can be formulated as a classic GET call with the hash as a query parameter, for example /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123"}}. GET requests are supported by default by practically every CDN, every reverse proxy, and every browser cache, without needing to implement GraphQL specific cache logic in the CDN. This is a significant difference from classic POST based GraphQL requests, which most CDN configurations fundamentally do not cache.
For Varnish or another reverse proxy setup in front of Magento, that means the cache key can simply be the full URL including the hash parameter, supplemented with relevant cache tags for invalidation on data changes. For heavily trafficked but rarely changing queries, for example category navigation data, this combination of persisted queries and CDN caching can noticeably reduce server load, because many requests never reach the Magento backend at all anymore.
8. Client side integration with Apollo Client
On the client side, Apollo Client handles GraphQL communication in many storefront projects, and the library @apollo/client/link/persisted-queries already implements the APQ protocol completely, without needing to write custom hash calculation code. The createPersistedQueryLink wrapper automatically computes the SHA-256 hash of every outgoing query, initially sends only the hash, and automatically retries with the full query text on a PersistedQueryNotFound error.
For a Hyvä frontend that executes GraphQL requests directly through fetch instead of through Apollo Client, this logic has to be rebuilt manually: compute the hash, send only the hash first, retry the same request with the full text on PersistedQueryNotFound. The implementation effort is manageable, roughly 30 to 50 lines of JavaScript, but it is especially worth it in combination with the CDN caching described in the previous section.
9. Regular queries vs. persisted queries compared
The following table compares both approaches based on the most important practical criteria.
| Criterion | Regular GraphQL query | Persisted query |
|---|---|---|
| Payload size | Full query text on every request | Only a few bytes of hash after initial registration |
| HTTP method | Usually POST, not CDN cacheable | GET possible, cacheable by default |
| Attack surface | Arbitrary ad hoc queries possible | Only registered hashes are accepted |
| Implementation effort | None, GraphQL standard | Custom plugin and storage backend needed |
| Migration of existing clients | No effort | Client library or custom fetch logic needed |
For small, internal projects with full control over all clients, the effort for persisted queries is often not justified. As soon as a publicly reachable storefront API with high traffic or external frontend clients is involved, the benefits in security and caching clearly outweigh the cost.
Mironsoft
Magento 2 GraphQL security and performance
GraphQL API running without query whitelisting?
We implement persisted queries for your Magento storefront API: custom plugin, Redis based storage, client integration, and CDN caching for noticeably lower server load.
Plugin development
APQ protocol as a plugin on the GraphQL controller
Query whitelisting
Securing strict persisted queries against arbitrary API usage
CDN integration
GET based caching in front of Magento with Varnish or an external CDN
10. Summary
Persisted queries in Magento GraphQL solve three connected problems: reduced payload size by transmitting a hash instead of the full query text, lower attack surface through whitelisting of registered queries, and the possibility of classic GET based CDN caching, which normally does not work for POST based GraphQL requests. Magento does not ship native support for this, but the APQ protocol can be rebuilt with manageable effort through a plugin on the GraphQL controller.
The effort of a complete implementation pays off especially for publicly reachable storefront APIs with high traffic. Redis as a storage backend for the hash to query mapping, clean handling of the two step protocol, and a deliberate choice between automatic and strict persisted queries together form a system that noticeably improves both performance and security of the GraphQL API.
Persisted Queries in Magento GraphQL — Key Takeaways
Two step protocol
Send the hash first, resend the full text once on PersistedQueryNotFound.
Custom implementation required
Magento has no native APQ, a plugin on the GraphQL controller handles hash resolution.
Security through whitelisting
Strict persisted queries only accept pre registered hashes, no ad hoc queries at runtime.
CDN caching via GET
Hash based GET requests are compatible with standard CDN caching, POST queries usually are not.