Persisted Queries in Magento GraphQL einsetzen
AI generated
M2
di.xml
Magento 2 · GraphQL · Sicherheit & Performance
Persisted Queries in Magento GraphQL einsetzen
Query-Hashes statt vollem Text, weniger Angriffsfläche

Persisted Queries ersetzen den vollständigen GraphQL-Query-Text durch einen kurzen Hash, der vorab auf dem Server registriert wurde. Das reduziert die Payload-Größe, verhindert beliebige Ad-hoc-Queries gegen die Storefront-API und macht GraphQL-Requests über GET-Aufrufe sogar für Standard-CDN-Caching zugänglich.

17 Min. Lesezeit Query-Hash · Whitelisting · CDN-Caching · Apollo Client Magento 2.4.x · GraphQL

1. Welches Problem Persisted Queries lösen

Eine typische GraphQL-Query für eine Produktseite kann, inklusive aller verschachtelten Felder und Fragmente, mehrere Kilobyte an Text umfassen. Bei jedem Request wird dieser komplette Query-String über die Leitung geschickt, obwohl sich der Text zwischen zwei Aufrufen derselben Seite meist überhaupt nicht ändert. Persisted Queries setzen genau hier an: statt des vollständigen Textes überträgt der Client nur einen kurzen, eindeutigen Hash, der die Query bereits vorab beim Server registriert hat.

Neben der reinen Payload-Reduktion lösen Persisted Queries ein zweites, oft unterschätztes Problem: die Angriffsfläche einer offenen GraphQL-API. Ohne Einschränkung kann jeder Client beliebige, auch sehr teure oder tief verschachtelte Queries an die Storefront-API senden, was zu Denial-of-Service-artigen Lastspitzen führen kann. Ein Persisted-Queries-Setup, das nur registrierte Hashes akzeptiert, verwandelt die offene API faktisch in eine Whitelist bekannter, geprüfter Queries.

Für Magento-Shops mit hohem Traffic ist der dritte Vorteil oft der entscheidende: GraphQL-Requests werden traditionell als POST-Request gesendet, weil der Query-Text im Body übertragen wird, und POST-Requests werden von den meisten CDNs und Reverse-Proxys standardmäßig nicht gecacht. Persisted Queries können dagegen als GET-Request mit dem Hash in der URL gesendet werden, was klassisches HTTP-Caching auf CDN-Ebene ermöglicht, ganz ohne GraphQL-spezifische Cache-Logik im CDN.

2. Funktionsweise: Query-Hash statt Query-Text

Das von Apollo etablierte Protokoll für Automatic Persisted Queries (APQ) funktioniert in zwei Schritten. Beim ersten Aufruf einer neuen Query sendet der Client zunächst nur den SHA-256-Hash des Query-Textes über extensions.persistedQuery.sha256Hash. Kennt der Server diesen Hash noch nicht, antwortet er mit dem Fehlercode PersistedQueryNotFound. Der Client reagiert darauf, indem er denselben Request wiederholt, dieses Mal jedoch mit dem vollständigen Query-Text zusätzlich zum Hash. Der Server berechnet den Hash aus dem empfangenen Text, prüft ihn gegen den übermittelten Hash, und speichert die Zuordnung Hash zu Query-Text dauerhaft.

Ab diesem Zeitpunkt reicht bei jedem weiteren Aufruf derselben Query nur noch der Hash, ohne den vollständigen Text erneut zu übertragen. Da derselbe Query-Text über viele Nutzer und Sitzungen hinweg identisch bleibt, etwa die Standard-Produktseiten-Query eines Shops, profitieren nach der Erstregistrierung praktisch alle nachfolgenden Requests von der reduzierten Payload. Dieses Zwei-Schritte-Verfahren ist der Kern jeder Persisted-Queries-Implementierung, unabhängig vom konkreten Backend.

3. Persisted Queries in Magento: nicht nativ, aber umsetzbar

Magentos GraphQL-Implementierung bringt Persisted Queries nicht als eingebautes Feature mit, im Gegensatz etwa zu Apollo Server. Für ein Magento-Projekt bedeutet das, das APQ-Protokoll über einen eigenen Plugin auf dem GraphQL-Frontcontroller nachzubauen. Der grundsätzliche Ablauf bleibt identisch zum Apollo-Standard: eingehende Requests werden auf extensions.persistedQuery geprüft, bekannte Hashes werden zum gespeicherten Query-Text aufgelöst, unbekannte Hashes lösen die PersistedQueryNotFound-Antwort aus.

Der Ansatzpunkt für diese Implementierung ist der Magento\GraphQl\Controller\GraphQl-Controller beziehungsweise ein around-Plugin darauf, das den Request abfängt, bevor die eigentliche Query-Ausführung beginnt. Bei einem gefundenen Hash wird der zugehörige Query-Text aus dem Speicher geladen und in den Request eingefügt, sodass der restliche Magento-GraphQL-Stack unverändert weiterarbeitet, als wäre der volle Text von Anfang an gesendet worden.


<?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. Eigene Plugin-Implementierung für Hash-Auflösung

Über die reine Auflösung hinaus muss eine produktionsreife Implementierung mehrere Randfälle abdecken. Ein manipulierter Hash, der nicht zum tatsächlich gesendeten Query-Text passt, darf nicht akzeptiert werden, sonst ließe sich das Whitelisting durch einen falschen Hash umgehen. Ebenso muss eine Obergrenze für die Größe eingehender Query-Texte gelten, damit die Erstregistrierung selbst nicht als Angriffsvektor für übergroße Payloads missbraucht wird.

Ein zweiter wichtiger Aspekt betrifft die Rückwärtskompatibilität: nicht jeder Client unterstützt Persisted Queries, insbesondere während einer schrittweisen Migration. Der Plugin-Code sollte deshalb Requests ohne extensions.persistedQuery unverändert an die normale Query-Verarbeitung durchreichen, statt sie abzulehnen. So können klassische und persisted Anfragen parallel funktionieren, bis die Migration auf allen Frontend-Clients abgeschlossen ist.

5. Speicherung der Query-Zuordnung in Redis

Die Zuordnung von Hash zu Query-Text muss über mehrere Requests und im Idealfall über mehrere Webserver-Instanzen hinweg verfügbar sein, ein einfacher In-Memory-Cache pro PHP-Prozess reicht dafür nicht aus. Redis eignet sich hervorragend als Speicher-Backend, weil Magento in den meisten produktiven Setups ohnehin bereits eine Redis-Instanz für Session- oder Cache-Daten betreibt. Der Schlüssel ist der SHA-256-Hash, der Wert der vollständige Query-Text, mit einer angemessenen TTL, damit nie genutzte Queries irgendwann automatisch ablaufen.


<?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. Sicherheitsgewinn durch Query-Whitelisting

Der Sicherheitsaspekt von Persisted Queries geht über reine Performance hinaus. In einer verschärften Ausbaustufe akzeptiert der Server ausschließlich Hashes, die vorab über einen kontrollierten Build- oder Deploy-Prozess registriert wurden, nicht über Ad-hoc-Registrierung durch beliebige Clients zur Laufzeit. Dieses Modell wird oft als strikte Persisted Queries bezeichnet: nur Queries, die tatsächlich im Frontend-Code des eigenen Teams vorkommen, können jemals ausgeführt werden, jede andere Query, egal wie harmlos sie aussieht, wird abgelehnt.

Für Magento-Shops mit einer öffentlich erreichbaren Storefront-API reduziert dieses Modell das Risiko erheblich, dass Angreifer gezielt teure, tief verschachtelte Queries konstruieren, um die Datenbank oder den Cache-Layer zu überlasten. Kombiniert mit klassischer Query-Depth-Limitierung und Rate-Limiting entsteht so ein mehrschichtiges Sicherheitskonzept, bei dem Persisted Queries die erste und wirksamste Verteidigungslinie gegen beliebige Query-Konstruktion bilden.

7. CDN-Caching mit GET-Requests

Sobald eine Query nur noch als kurzer Hash übertragen wird, lässt sich der gesamte Request als klassischer GET-Aufruf mit dem Hash als Query-Parameter formulieren, etwa /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123"}}. GET-Requests werden von praktisch jedem CDN, jedem Reverse-Proxy und jedem Browser-Cache standardmäßig unterstützt, ohne dass GraphQL-spezifische Cache-Logik im CDN implementiert werden muss. Das ist ein erheblicher Unterschied zu klassischen POST-basierten GraphQL-Requests, die von den meisten CDN-Konfigurationen grundsätzlich nicht gecacht werden.

Für Varnish oder ein anderes Reverse-Proxy-Setup vor Magento bedeutet das: der Cache-Key kann einfach die vollständige URL inklusive Hash-Parameter sein, ergänzt um relevante Cache-Tags für die Invalidierung bei Datenänderungen. Für stark frequentierte, aber selten wechselnde Queries, etwa Kategorie-Navigationsdaten, kann diese Kombination aus Persisted Queries und CDN-Caching die Serverlast spürbar senken, weil viele Requests nie mehr bis zum Magento-Backend durchdringen.

8. Client-seitige Integration mit Apollo Client

Auf Client-Seite übernimmt in vielen Storefront-Projekten Apollo Client die GraphQL-Kommunikation, und die Bibliothek @apollo/client/link/persisted-queries implementiert das APQ-Protokoll bereits vollständig, ohne dass eigener Code für die Hash-Berechnung geschrieben werden muss. Der createPersistedQueryLink-Wrapper berechnet automatisch den SHA-256-Hash jeder ausgehenden Query, sendet zunächst nur den Hash, und reagiert bei einem PersistedQueryNotFound-Fehler automatisch mit einer Wiederholung inklusive vollständigem Query-Text.

Für ein Hyvä-Frontend, das GraphQL-Requests direkt über Fetch statt über Apollo Client ausführt, muss diese Logik manuell nachgebaut werden: Hash berechnen, zuerst nur den Hash senden, bei PersistedQueryNotFound denselben Request mit vollständigem Text wiederholen. Der Implementierungsaufwand ist überschaubar, etwa 30 bis 50 Zeilen JavaScript, lohnt sich aber besonders in Kombination mit dem im vorherigen Abschnitt beschriebenen CDN-Caching.

9. Normale Queries vs. Persisted Queries im Vergleich

Die folgende Tabelle stellt beide Ansätze anhand der wichtigsten praktischen Kriterien gegenüber.

Kriterium Normale GraphQL-Query Persisted Query
Payload-Größe Voller Query-Text bei jedem Request Nur wenige Bytes Hash nach Erstregistrierung
HTTP-Methode Meist POST, nicht CDN-cachebar GET möglich, standardmäßig CDN-cachebar
Angriffsfläche Beliebige Ad-hoc-Queries möglich Nur registrierte Hashes werden akzeptiert
Implementierungsaufwand Keiner, GraphQL-Standard Eigenes Plugin und Speicher-Backend nötig
Migration bestehender Clients Kein Aufwand Client-Bibliothek oder eigene Fetch-Logik nötig

Für kleine, interne Projekte mit vollständiger Kontrolle über alle Clients ist der Aufwand für Persisted Queries oft nicht gerechtfertigt. Sobald eine öffentlich erreichbare Storefront-API mit hohem Traffic oder externen Frontend-Clients im Spiel ist, überwiegen die Vorteile bei Sicherheit und Caching deutlich.

Mironsoft

Magento 2 GraphQL-Sicherheit und Performance

GraphQL-API ohne Query-Whitelisting im Einsatz?

Wir implementieren Persisted Queries für eure Magento-Storefront-API: eigenes Plugin, Redis-basierte Speicherung, Client-Integration und CDN-Caching für spürbar geringere Serverlast.

Plugin-Entwicklung

APQ-Protokoll als Plugin auf dem GraphQL-Controller

Query-Whitelisting

Strikte Persisted Queries gegen beliebige API-Nutzung absichern

CDN-Integration

GET-basiertes Caching vor Magento mit Varnish oder externem CDN

10. Zusammenfassung

Persisted Queries in Magento GraphQL lösen drei zusammenhängende Probleme: reduzierte Payload-Größe durch Übertragung eines Hashes statt des vollständigen Query-Textes, geringere Angriffsfläche durch Whitelisting registrierter Queries, und die Möglichkeit klassischen GET-basierten CDN-Cachings, das für POST-basierte GraphQL-Requests normalerweise nicht funktioniert. Magento bringt dafür keine native Unterstützung mit, das APQ-Protokoll lässt sich aber mit überschaubarem Aufwand über einen Plugin auf dem GraphQL-Controller nachbauen.

Der Aufwand für eine vollständige Implementierung lohnt sich vor allem für öffentlich erreichbare Storefront-APIs mit hohem Traffic. Redis als Speicher-Backend für die Hash-zu-Query-Zuordnung, saubere Behandlung des Zwei-Schritte-Protokolls und eine bewusste Entscheidung zwischen Automatic und strikten Persisted Queries bilden zusammen ein System, das sowohl Performance als auch Sicherheit der GraphQL-API spürbar verbessert.

Persisted Queries in Magento GraphQL — Das Wichtigste auf einen Blick

Zwei-Schritte-Protokoll

Erst Hash senden, bei PersistedQueryNotFound einmalig den vollständigen Text nachreichen.

Eigene Implementierung nötig

Magento hat kein natives APQ, ein Plugin auf dem GraphQL-Controller übernimmt die Hash-Auflösung.

Sicherheit durch Whitelisting

Strikte Persisted Queries akzeptieren nur vorab registrierte Hashes, keine Ad-hoc-Queries zur Laufzeit.

CDN-Caching via GET

Hash-basierte GET-Requests sind mit Standard-CDN-Caching kompatibel, POST-Queries üblicherweise nicht.

11. FAQ: Persisted Queries in Magento GraphQL

1Was sind Persisted Queries?
Ein Hash statt des vollständigen Query-Textes wird übertragen, nachdem die Query vorab registriert wurde.
2Unterstützt Magento das nativ?
Nein, ein eigener Plugin auf dem GraphQL-Controller bildet das Protokoll nach.
3Wie läuft das Zwei-Schritte-Protokoll?
Hash zuerst, bei PersistedQueryNotFound den vollständigen Text einmalig nachsenden.
4Wo wird die Zuordnung gespeichert?
Idealerweise in Redis mit angemessener TTL für nie genutzte Queries.
5Wie verbessert das die Sicherheit?
Strikte Varianten akzeptieren nur vorab registrierte Hashes, keine beliebigen Ad-hoc-Queries.
6Warum ermöglicht das CDN-Caching?
Der Request kann als GET mit Hash-Parameter gesendet werden, GET wird von CDNs standardmäßig gecacht.
7Welche Client-Bibliothek hilft?
@apollo/client/link/persisted-queries übernimmt Hash-Berechnung und Retry automatisch.
8Was ist bei manipulierten Hashes zu tun?
Server muss den Text selbst hashen und mit dem übermittelten Hash vergleichen, bevor er speichert.
9Lohnt sich das für kleine Projekte?
Meist nicht, der Nutzen zeigt sich vor allem bei hohem Traffic und öffentlicher API.
10Können beide Query-Arten parallel laufen?
Ja, Requests ohne persistedQuery-Extension werden unverändert normal verarbeitet.