Persisted queries
GraphQL only A plain HTTP API has no operations to register.
A persisted query is an operation your service already knows. The client sends a SHA-256 hash instead of the document, the edge looks the hash up in your service’s registry and puts the document it finds back into the request. What reaches your origin is an ordinary GraphQL request, so your origin needs no support for any of this; see what your origin receives. Three things follow, and you configure them separately:
- Requests get small. A hash is 64 characters whatever the operation is. That
matters most for
GETrequests, where the operation travels in the URL and a large document runs into the 8 KB URL limit. - The registry can become an allowlist. If the only operations your service runs are the ones you registered, an attacker cannot craft a new one, however valid it is.
- Your inventory becomes readable.
gpilot pq listlists the operations you registered andgpilot apq lsthe ones your clients registered on their own. Together they answer “what runs against this service”.
How a client sends one
Section titled “How a client sends one”GraphPilot speaks the Apollo protocol: the hash travels in the persistedQuery
extension, version 1.
{ "variables": { "id": "42" }, "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "8f3c…" } }}The same request as a GET, which is the form that is cacheable in shared caches
downstream of the edge:
GET /graphql?extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%228f3c…%22%7D%7D&variables=%7B%22id%22%3A%2242%22%7DThe hash is the SHA-256 of the document text exactly as it is registered, hex encoded. It is not normalized, reformatted or reordered first, so the same operation written with different whitespace is a different hash.
What your origin receives
Section titled “What your origin receives”The hash never leaves the edge. A client sends this:
{ "variables": { "id": "42" }, "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "8f3c…" } }}and your origin receives this:
{ "query": "query Product($id: ID!) { product(id: $id) { name price } }", "variables": { "id": "42" }}The document came from the registry, not from the client. If a request carries a hash and a document together and the registry holds that hash, the registered document runs; otherwise anyone holding one valid hash could run a different operation under it.
The persistedQuery extension was removed. The edge adds selections of its
own further down the pipeline, for instance the fields your surrogate
keys are built from, so the hash would no longer
describe the document your origin receives.
Everything below sees the resolved document. Cache lookup, surrogate keys, selection set expansion and the request limits all run on the operation, not on the hash, so an operation sent as a hash and the same operation sent in full share one cache entry.
Persisted queries are therefore a client-side and edge-side change only: you can switch them on and off for a service whose origin you cannot modify.
Registering your operations
Section titled “Registering your operations”Push the manifest your client tooling already produces:
gpilot pq push ./src/gql/persisted-documents.jsonThe format is detected from the file’s contents rather than its name, because the same shape ships under several names:
| Format | Produced by | Usual file name |
|---|---|---|
apollo | Apollo Client, Apollo Kotlin, Rover | persisted-query-manifest. |
map | graphql-codegen, Relay, Hive, urql | persisted-documents., persisted_queries., po.json |
relay-array | Relay compiler | persisted_queries. (array form) |
operation- | Apollo Kotlin and Apollo iOS (legacy) | operationOutput. |
graphql- | Hand-written operations | *.graphql, *.gql |
gpilot pq formats prints this list with a link to each tool’s documentation.
Every entry is verified before it is written: the id the manifest declares must be
the SHA-256 of the document beside it. An entry whose id is anything else is
reported rather than repaired, because a document registered under a hash your
clients do not send is never found. The report names the one configuration line
that fixes it, per tool. The common
cases are a Relay compiler still on its md5 default and a custom createOperationId in an Apollo config.
gpilot pq push --rehash registers each document under GraphPilot’s hash instead
of the id the manifest declares. That is only correct when you regenerate your
clients at the same time; on its own it registers documents under ids no client
sends.
A push is additive and idempotent. Nothing is removed, and re-sending an entry does nothing, so the operations of a release that is still in the field keep working while the next one registers alongside them. Remove operations explicitly when you want them gone:
gpilot pq rm 8f3c… 91ab…That revokes the entries and purges everything cached for them.
In your deployment pipeline
Section titled “In your deployment pipeline”Push before you ship the client, not after. A client that reaches a user before
its manifest reaches the registry sends hashes nothing can resolve. Under apq
that costs one extra round trip per operation; under only_registered it is a
403.
gpilot pq push ./dist/persisted-documents.json --dry-run # parse and verify onlygpilot pq push ./dist/persisted-documents.json# ...then deploy the clientConfiguration
Section titled “Configuration”Persisted queries are configured under [persisted_query] in your service’s gpilot.toml: four
independent switches (enabled, apq, only_registered, audit_unregistered), a preset that
names the common combinations, two cache lifetimes and two document ceilings. Every key, its
default, what each preset sets and the two refused combinations are in the configuration
reference.
[persisted_query]preset = "apq"apq = false # resolve hashes, but let only a push fill the registryenabled is off by default, and that is a defense. With resolution on, a client sending
random hashes costs a registry lookup per request.
The cache lifetimes are long for documents and short for misses. A resolved document stays in
the local cache of the location that resolved it for seven days, safely so: a hash points at one
document forever or at nothing, and gpilot pq rm purges rather than waiting for expiry. “This
hash resolves to nothing” is remembered for a minute, so a client repeating an unknown hash does
not cause a lookup per request, and a newly registered operation is seen quickly.
A registered document can have ceilings of its own. A document your team pushed is not the
same risk as one a stranger sends, so persisted_query.max_document_bytes and max_selections
are the ceilings to raise when a generated document outgrows 64 KiB, rather than raising the
[request] values for anonymous callers too. Lowering them bounds what a push can commit your
edge to. A push checks each document against the ceilings your service is deployed
with and marks an oversized one REJECTED without failing the rest of the batch. See where the limits apply.
Choosing a setup
Section titled “Choosing a setup”Bandwidth only, public API. preset = "apq". Clients register what they
send, nothing is refused, and you need no build step. On a service anyone may
call, the handshake is a write, so strangers can add to your registry and your
inventory stops answering the question it exists for.
You register from CI, and your API stays open. enabled = true with apq
off. Hashes resolve, free text still runs, so customers and scripts can send their
own operations, and only your push fills the registry.
Allowlist. preset = "trusted". Only registered operations run. Anything else
is refused with 403, including a full query sent as text and your own ad-hoc
tooling, so it belongs on a service whose only callers are clients you build.
Migrating onto the allowlist. preset = "trusted_migrating" first. Everything still runs,
and every operation the registry does not hold is logged as
PERSISTED_QUERY_UNTRUSTED_OPERATION. Once nothing new appears, switch to trusted. Migrating
to trusted documents walks through it.
What the edge answers
Section titled “What the edge answers”A client receives PERSISTED_QUERY_NOT_SUPPORTED for a hash sent to a service with
enabled = false, PERSISTED_QUERY_NOT_FOUND for a hash the registry does not hold, FORBIDDEN
(403) for an operation only_registered refuses, and METHOD_NOT_ALLOWED (405) for a
non-query sent as a GET. Your logs carry the finer codes behind them. Every code, with its status
and meaning, is in codes.
PERSISTED_QUERY_NOT_SUPPORTED and PERSISTED_QUERY_NOT_FOUND are GraphQL errors in a 200
response, because that is what the Apollo protocol expects: a client treats a 4xx as a transport
failure and would not resend the document.
If the registry is unreachable, an only_registered service refuses rather than falling back,
because an allowlist that opens in an outage has stopped being one. Every other setup runs the
operation. Locations that have
resolved a hash recently keep serving it from their local cache either way, so an outage does not
stop traffic everywhere at once.
Notes and limits
Section titled “Notes and limits”A hash addresses one operation, which is also what keeps its cache entry still. A client that
sends whole .gql files instead is keyed by the file, so editing any operation in it changes the
key of every operation in it. See the cache key covers the whole
document.
Documents from your registry are subject to the same
request limits as documents from clients, unless
persisted_query.max_document_bytes or max_selections set their own.
request.max_query_depth sets its depth ceiling for both, separately.
An operation sent to a path that is not a GraphQL path does not go around the allowlist: it is
refused with 404 and NOT_A_GRAPHQL_PATH, a
persisted query hash included. The allowlist covers every body sent to a GraphQL path
(request.graphql_paths), not only a JSON operation. An application/graphql document is read as one operation and checked like any
other; it reaches your origin as JSON, with Content-Type: application/json. Any other body
GraphPilot cannot read as a single operation (a JSON batch, a multipart upload, text, no content
type, or JSON that names no operation, such as a Relay-style {"doc_id": ...} persisted query)
is refused with 415 and UNSUPPORTED_GRAPHQL_BODY before it reaches your origin. It is
forwarded unchecked, as the bytes the client sent, only if you list its kind (batch,
multipart or other) in request.forward_unchecked_bodies,
which takes it past the allowlist, the introspection block and the document limits. A JSON body
that repeats a key, or nests more than 128 levels deep, is never forwarded that way: it is refused
with 400 and INVALID_GRAPHQL_REQUEST, because your origin might read a different operation
out of it than GraphPilot checked.
Persisted queries have no effect on subscriptions. A WebSocket upgrade bypasses the edge’s GraphQL handling entirely and is relayed as a tunnel, so nothing sent over that connection is resolved against the registry.