P
imvdmolen.nl
Blog

GraphQL Persisted Queries implementeren voor veilige en snelle API-aanroepen

GraphQL geeft clients veel vrijheid, maar in productieomgevingen is die vrijheid soms het laatste wat je wil. Elke willekeurige query die een client stuurt kan de database flink belasten, en zonder beperkingen staat de deur open voor misbruik. Persisted queries lossen dit probleem op door alleen vooraf geregistreerde queries toe te staan, terwijl ze tegelijk de payload van elke request drastisch verkleinen. Dat klinkt als twee vliegen in één klap, en in de praktijk klopt dat ook.

Wat persisted queries precies doen

Normaal gesproken stuurt een GraphQL-client bij elke request de volledige querystring mee. Die string wordt op de server geparsed, gevalideerd en uitgevoerd. Bij kleine queries valt dat mee, maar zodra je applicatie twintig velden ophaalt met geneste relaties loopt de payload al snel op. Persisted queries draaien dit om: de client stuurt alleen een hash, de server zoekt de bijbehorende query op en voert die uit.

{
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9aba0"
    }
  }
}

De server ontvangt deze hash, zoekt de query op in een register of cache, en voert hem uit. Als de hash onbekend is, geeft de server een foutmelding terug waarna de client de volledige query alsnog kan opsturen. Dit mechanisme heet Automatic Persisted Queries, of APQ. Apollo heeft dit protocol gepopulariseerd, maar het werkt met elke GraphQL-implementatie die je wil bouwen.

Wat dit veiliger maakt dan standaard GraphQL is het feit dat je in een strikte modus ook arbitraire queries kunt weigeren. Alleen queries met een bekende hash worden dan geaccepteerd. Dit voorkomt dat kwaadwillenden zelf queries bouwen om gevoelige data op te halen of de server te belasten met diep geneste requests.

APQ implementeren in PHP

Stel dat je een GraphQL-endpoint hebt gebouwd met de webonyx/graphql-php library, dan kun je persisted queries toevoegen zonder de queryverwerking volledig te herschrijven. Het idee is simpel: je onderschept de binnenkomende request voordat de query wordt geparsed, controleert of er een hash meegegeven is, en haalt de bijbehorende querystring op uit een opslagmechanisme.

Redis is hier een goede keuze voor, omdat queries al bij het opstarten van de client geregistreerd kunnen worden en je daarna geen database-queries nodig hebt voor elke lookup. Hieronder een vereenvoudigde middleware-structuur in PHP:

class PersistedQueryMiddleware
{
    public function __construct(private \Redis $redis) {}

    public function resolve(array $input): array
    {
        $hash = $input['extensions']['persistedQuery']['sha256Hash'] ?? null;

        if ($hash === null) {
            return $input;
        }

        $cachedQuery = $this->redis->get("pq:{$hash}");

        if ($cachedQuery === false) {
            if (empty($input['query'])) {
                throw new \RuntimeException('PersistedQueryNotFound', 404);
            }

            $computedHash = hash('sha256', $input['query']);

            if ($computedHash !== $hash) {
                throw new \RuntimeException('Hash mismatch', 400);
            }

            $this->redis->set("pq:{$hash}", $input['query']);
            return $input;
        }

        $input['query'] = $cachedQuery;
        return $input;
    }
}

De hash wordt altijd gecontroleerd op integriteit. Als de client een nieuwe query opstuurt om te registreren, bereken ik de SHA-256 hash van de querystring en vergelijk die met de meegestuurde hash. Komen ze niet overeen, dan wijs ik de request af. Zo voorkom je dat iemand een legitieme hash koppelt aan een andere query.

Strikte modus: alleen whitelisted queries toestaan

APQ met Redis is al een grote stap vooruit, maar de echte beveiliging zit in het volledig verbieden van ad-hoc queries. Daarvoor houd je een whitelist bij van hashes die bij deployment worden vastgelegd. Tijdens het bouwen van de frontend genereert je buildtool alle queries, berekent de hashes en schrijft die weg naar een manifest. Dat manifest laad je in bij de deploymentstap.

# Stap tijdens de build van de frontend
graphql-codegen && node scripts/extract-queries.js > query-manifest.json

Het extractiescript itereert over alle .graphql-bestanden in de frontend, berekent per query de SHA-256 hash en schrijft het resultaat weg als JSON. Bij deployment laad je dat manifest in Redis:

$manifest = json_decode(file_get_contents('query-manifest.json'), true);

foreach ($manifest as $entry) {
    $redis->set("pq:{$entry['hash']}", $entry['query']);
}

Daarna pas je de middleware aan zodat queries zonder bekende hash direct geweigerd worden, ook als de client een volledige querystring meestuurt:

if ($cachedQuery === false) {
    throw new \RuntimeException('PersistedQueryNotFound', 404);
}

Dit is de modus die ik zet op publieke endpoints waar de API uitsluitend door eigen clients wordt aangesproken. Voor interne developer-tooling of GraphiQL-omgevingen schakel ik de strikte modus weer uit, anders wordt debuggen een nachtmerrie.

Caching en performance winst meten

Naast de veiligheidswinst is er een merkbaar verschil in netwerkprestaties, met name op mobiele verbindingen of bij clients die veel kleine queries uitvoeren. Stel dat een typische query als string 400 bytes is. Na persistentie stuurt de client alleen een hash van 64 tekens mee. Vermenigvuldig dat over duizenden requests per dag en je bespaart serieus bandbreedte.

Bovendien vervalt de parseer- en validatiestap voor bekende queries niet volledig, maar je kunt de validatie wel cachen. Bij webonyx/graphql-php kun je een DocumentNode opslaan na de eerste parse, zodat je bij herhaalde aanroepen direct naar de executiestap gaat:

$cacheKey = "pq:parsed:{$hash}";
$cached = $this->cache->get($cacheKey);

if ($cached instanceof DocumentNode) {
    return $cached;
}

$document = Parser::parse($query);
$this->cache->set($cacheKey, $document, 3600);

return $document;

Let op: DocumentNode-objecten zijn niet zomaar serialiseerbaar. Ik werk hier met een PSR-6 cache die PHP-objecten kan opslaan via serialisatie, zoals Symfony Cache met een filesystem- of APCu-adapter. Redis vereist in dit geval een extra stap voor serialisatie, dus ik kies doorgaans voor APCu als de applicatie op één server draait.

Het mooie aan de gecombineerde aanpak is dat de eerste aanroep de zware kant op gaat: query ophalen, parsen, valideren, document cachen. Alle aanroepen daarna zijn snel. Dat gedragspatroon lijkt op wat je ziet bij gecompileerde Blade-templates in Laravel, en de mentale werklast om het te begrijpen is daardoor vrij laag voor PHP-developers die al vertrouwd zijn met dat framework.

Iets waar ik zelf tegenaan liep: als je queries activeert via een CDN met caching op HTTP-niveau, werkt het persistentiepatroon nog beter. GET-requests zijn cacheable, POST-requests niet. Sommige APQ-implementaties sturen bekende queries via GET mee als queryparameter, waardoor een CDN als Cloudflare ze volledig kan cachen op de edge. Voor data die niet per gebruiker verschilt, zoals navigatiestructuren of productcatalogi, scheelt dat enorm in serverbelasting.

Persisted queries zijn geen oplossing die je overal zomaar neerzet. Voor interne APIs tussen microservices of projecten waar de frontend en backend toch al strak gekoppeld zijn, voegt de overhead meer toe dan het oplevert. Maar zodra je een GraphQL-endpoint publiek maakt of meerdere clients bedient die elk hun eigen querying doen, is dit de aanpak die ik standaard meeneem van het begin. Het is het soort beslissing dat je het beste vroeg maakt, want een whitelist achteraf opbouwen voor een bestaande API met honderden queries is een stuk meer werk dan het meteen inbouwen.