API · Graphql

GraphQL Persisted Queries — A Field Guide

GraphQL Persisted Queries — A Field Guide is the work that makes the systems talk. The API is the contract between the producer and the consumer; the contract is what determines.

John Kihiu12 min read

A public GraphQL endpoint that accepts arbitrary query strings is a bigger attack surface than most teams intend to ship. Every client can send any query the schema technically permits — including deeply nested ones that fan out into expensive database work, or queries built straight off introspection to pull fields the UI never needed. Persisted queries close that gap by replacing free-form query text with a fixed, known set of operations the server will actually execute.

The problem with client-supplied queries

Standard GraphQL over HTTP POST sends the full query document on every request. That has three costs. First, it's a denial-of-service vector: a client (malicious or just careless) can send an expensive nested query and the server has no way to distinguish it from a legitimate one without per-query cost analysis. Second, it exposes your entire schema to introspection-driven exploration in production, which you may not want for an internal or partner-only API. Third, because the query text is arbitrary and lives in the POST body, you lose HTTP-level caching — GET requests are cacheable by CDNs and browsers, POST bodies generally are not.

Sending a hash instead of a query

The core idea: instead of the client sending the full query string, it sends a short hash that identifies the query, and the server looks that hash up in a registry before executing anything. There are two common flavors. A build-time allowlist extracts every query used by the client at build time, uploads the hash-to-query mapping to the server ahead of deployment, and the server rejects any hash it doesn't recognize — no unregistered query ever runs. Automatic Persisted Queries (APQ), Apollo's variant, builds the registry lazily at runtime instead of at build time.

Apollo's APQ protocol specifically

APQ works as a two-step handshake. On the first attempt, the client sends only the hash of the query it wants to run:

JSON · APQ REQUEST
{
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"
    }
  }
}

If the server has never seen that hash, it responds with a PersistedQueryNotFound error instead of executing anything. The client then resends the request once, this time including both the hash and the full query text, so the server can verify the hash matches and store the mapping:

JSON · APQ REGISTRATION
{
  "query": "query GetUser($id: ID!) { user(id: $id) { name email } }",
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"
    }
  }
}

Every subsequent call for that query sends just the hash again. The net effect: after the first round-trip, the wire payload for a query shrinks from the full document to a 64-character hash.

APQ vs a strict allowlist

APQ registers whatever hash-query pairs clients send it, which is convenient but still lets a client register a brand-new query at runtime. A strict build-time allowlist refuses anything not pre-registered at deploy time, which is the stronger security posture for a public-facing production API — APQ alone doesn't stop introspection-driven queries from being registered and run.

Unlocking GET requests and CDN caching

Because a hash is short and stable, it fits cleanly into a URL query parameter, which means a persisted query can be sent as an HTTP GET rather than a POST: GET /graphql?extensions={"persistedQuery":{"sha256Hash":"ecf4..."}}. GET requests with a deterministic URL are exactly what CDNs, browser caches, and reverse proxies know how to cache. A query that would otherwise require a POST bypassing every layer of HTTP caching becomes a cacheable, CDN-fronted request — often the biggest practical performance win from adopting persisted queries, separate from the security benefit.

The security payoff of a strict allowlist

Once only known hashes execute, arbitrary client-crafted queries — including ones built by scraping your schema via introspection — simply can't run, because they were never registered. This is the concrete answer to the DoS and bandwidth concerns above: expensive or exploratory queries have no path to execution unless someone deliberately added them to the build. Many teams pair this with disabling introspection in production entirely, since a locked-down allowlist makes introspection's main remaining use (ad hoc exploration) a liability rather than a convenience.

Persisted queries add a build step and a registry to maintain, and they're a poor fit for genuinely ad hoc query tools aimed at internal users. For a client-facing production API, though, the trade is usually worth it: smaller requests, GET-cacheable traffic, and a server that only ever executes queries you already know about.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.