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.
Inhaltsverzeichnis
- 1. Welches Problem Persisted Queries lösen
- 2. Funktionsweise: Query-Hash statt Query-Text
- 3. Persisted Queries in Magento nicht nativ, aber umsetzbar
- 4. Eigene Plugin-Implementierung für Hash-Auflösung
- 5. Speicherung der Query-Zuordnung in Redis
- 6. Sicherheitsgewinn durch Query-Whitelisting
- 7. CDN-Caching mit GET-Requests
- 8. Client-seitige Integration mit Apollo Client
- 9. Normale Queries vs. Persisted Queries im Vergleich
- 10. Zusammenfassung
- 11. FAQ
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.