Using Persisted Queries in Magento GraphQL
AI generated
M2
di.xml
Magento 2 · GraphQL · Security & Performance
Using Persisted Queries in Magento GraphQL
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.

17 min read Query Hash · Whitelisting · CDN Caching · Apollo Client Magento 2.4.x · GraphQL

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.

11. FAQ: Persisted Queries in Magento GraphQL

1What are persisted queries?
A hash is transmitted instead of the full query text, after the query was pre registered.
2Does Magento support this natively?
No, a custom plugin on the GraphQL controller rebuilds the protocol.
3How does the two step protocol work?
Hash first, resend the full text once on PersistedQueryNotFound.
4Where is the mapping stored?
Ideally in Redis with a reasonable TTL for never used queries.
5How does this improve security?
Strict variants only accept pre registered hashes, no arbitrary ad hoc queries.
6Why does this enable CDN caching?
The request can be sent as GET with a hash parameter, GET is cached by CDNs by default.
7Which client library helps?
@apollo/client/link/persisted-queries handles hash calculation and retry automatically.
8What about tampered hashes?
The server must hash the text itself and compare it against the submitted hash before storing.
9Worth it for small projects?
Usually not, the benefit shows especially with high traffic and a public API.
10Can both query types run in parallel?
Yes, requests without the persistedQuery extension are processed normally, unchanged.