Persisted queries and APQ
Replacing query text with a SHA-256 hash: Apollo's three-step APQ handshake, hashes that do not match their text, persisted queries over GET and the mutation that must stay off it, trusted documents as an allowlist, and where each stack resolves the document: a Vert.x route in front of SmallRye, a SchemaExtension in Strawberry.
Chapter 13 ended with a query that had stopped costing source calls. It still costs something on every request: the client sends the whole text, the server hashes it, finds it in a cache or parses and validates it, and only then executes. Chapter 1 pointed at the other side of the same cost. A POST to one URL cannot be cached by anything between the client and the server, and the GET form that could be cached needs a URL that carries a document. This chapter replaces the document with its hash.
The code is in examples/15-persisted-queries/. It starts from the Chapter 13 example, so the schema, the seed and the error contract are unchanged, and adds the protocol in front of each engine. Four services run: an APQ instance and a trusted instance per stack, from one image each. The chapter does not repeat Chapter 14’s limits, and a production service would carry both.
Sending the same text every time
Chapter 1 described variables as the reason a query can be a constant: the text stays fixed, the inputs change, and a server can cache the parse. Chapter 6 priced what the server does with that text. Parsing and validating a document needs only the document and the schema, and it costs CPU on every request that arrives with text the server has already seen.
The bytes matter too. The example’s CargoDetail operation, a cargo with its itinerary, seven events and three fragments, is 580 characters. Sent with its variables it is a 624-byte request body. The same request by hash is 163 bytes, and the difference grows with the document: clients ship documents of several kilobytes because fragments from many components add up. A small document gains little. Chapter 13’s four-cargo page is 191 bytes as plain text and 132 as a hash, because the extensions wrapper is most of what remains.
A hash buys three things, each with its own price:
- Bytes on the wire. The client sends 64 hexadecimal characters instead of the document. The price is a registration round trip the first time the server has not seen the hash.
- A URL a cache can key. The hash fits in a
GETURL with the variables, so a cache or a CDN can store the response. The price is thatGETcarries only queries, and that every part of the URL is visible in logs. - An allowlist. If the server accepts only hashes it already knows, text it has not been given cannot run at all. The price is a deploy step: the documents must reach the server before the client that sends them.
The first two come from Automatic Persisted Queries, Apollo’s protocol. The third is a different arrangement of the same hash, called trusted documents, and the chapter treats them as two modes because they answer different questions.
The APQ handshake
The protocol has one extension, extensions.persistedQuery, with two fields: version, which is 1, and sha256Hash, the lowercase hexadecimal SHA-256 of the exact query text. The server’s behaviour has three cases, and a client walks through them in order the first time it sends a document.
Figure 15.1 follows CargoDetail through them on the Quarkus instance, with the byte counts curl measured.
- The client sends the hash alone. The server has never seen it, so it answers with an error whose code is
PERSISTED_QUERY_NOT_FOUNDand whose message isPersistedQueryNotFound. Both stacks answer200with nodata(Strawberry writes"data": null), and no source is called. - The client repeats the request with the hash and the text. The server recomputes the hash of the text. If it matches, the server stores the text under the hash and executes the operation.
- From then on the hash alone is enough. The server finds the text and executes it. The response is the one the full request would have produced, and
verify.shcompares the source-call log of the hash-only request with the full one, key for key.
The server never trusts the hash it receives. It computes sha256(text) itself when text arrives, which is why the next case exists.
Hashes that do not match
A client could send the hash of one document with the text of another. If the server stored the pair as sent, the next client to ask for that hash would run the wrong document, and a request that looks like a cache hit for a harmless query would execute something else. So the server recomputes the hash and refuses a mismatch. Both stacks answer with the same error:
{"errors":[{"message":"provided sha does not match query","extensions":{"code":"BAD_USER_INPUT"}}]}
That is the code the example already uses for a value that passed coercion and is still unacceptable, and it carries no data and calls no source. Nothing is stored. Python adds "data": null, as for every request error.
Over GET
A hash makes the GET form practical. The request has no body: the variables and the extensions travel in the URL, each as URL-encoded JSON.
curl -G localhost:8250/graphql -H 'Accept: application/json' \
--data-urlencode 'variables={"id":"ABC123"}' \
--data-urlencode 'extensions={"persistedQuery":{"version":1,"sha256Hash":"<TrackCargo hash>"}}'
The parameter names are GraphQL-over-HTTP’s; leaving out query is not, because the specification requires it on GET. The hash-only form is Apollo’s convention on top of the standard parameters, and the persisted-documents proposal is not in the current draft. Apollo’s own client sends hashed queries as GET when configured to and keeps mutations and full-text requests on POST.
Three details of the example matter when something else sits in front of it.
Quarkus turns GET on in configuration. The example sets quarkus.smallrye-graphql.http.get.enabled=true, and the next section shows how a mutation stays off GET. Strawberry answers a GET by its Accept header. A GET with no query parameter is treated as a browser asking for GraphiQL when Accept includes text/html or */*, and Strawberry answers 200 text/html with the IDE. The hash-only request has no query parameter by design, so a client must send Accept: application/json. Apollo Client 4 sends application/graphql-response+json,application/json;q=0.9, which passes, and verify.sh sends application/json. Apollo Client 3 sends */*, and a hash-only GET from it would get Strawberry’s IDE. A bare curl sends */* too and receives a page of HTML. Quarkus answers */* with JSON and Accept: text/html with 406. When a cached persisted GET returns something other than JSON, the Accept header is the first thing to check, and Chapter 16 returns to it.
GET and operationName. An empty operationName is treated as absent, and a non-empty one must name an operation in the document, or the Quarkus route answers 400 with BAD_USER_INPUT. For a single-operation document the operation is already determined, so the route hands the request on without operationName; a GET by hash that carries its name still works.
A mutation stays off GET
Chapter 1 and Chapter 8 settled the rule: a mutation received over GET must be refused with a 4xx status and must not run. The specification’s section “GET” recommends 405 Method Not Allowed with an Allow header. A GET can be triggered by a link, an image tag or a prefetching browser, and a write behind it would need no cooperation from the user.
verify.sh registers RegisterHandling by POST with input that fails validation, and then sends the same hash by GET. Both stacks refuse it and write nothing. Strawberry answers 400 with the plain text mutations are not allowed when using GET.
On Quarkus the persisted-document route applies the rule to every GET before it hands the request on, for persisted and plain text alike: it refuses a GET whose selected operation is not a query with 405, Allow: POST and the same text, and the source-call log shows no appendEvent.
APQ is not an allowlist
Read the three handshake steps as an attacker would. Step 2 accepts any text that matches its own hash, and anyone can compute a SHA-256. APQ therefore lets any client register any document, and the hash does nothing to limit what runs. It is a bandwidth and caching tool. It is also a way to fill the server’s store: a client that registers documents without end forces the server to keep them, or to evict the ones other clients rely on.
The example bounds the store. Both stacks keep 1,000 documents: Caffeine through quarkus.cache.caffeine."persisted-documents".maximum-size=1000 on Quarkus, and cachetools.LRUCache(maxsize=1000) on Python. verify.sh registers 1,100 distinct documents on each APQ instance and then asks for all of them by hash. Exactly 1,000 resolve on both. On Python, which evicts the least recently used, the 100 that do not are the first 100 registered. Caffeine’s admission policy is Window TinyLFU, not LRU, so the example asserts only the count on Quarkus. An unresolved hash answers PersistedQueryNotFound. An evicted hash is not an outage, because the client treats it like a first request and sends the text again. It is a cost, and an attacker who can register documents can force it on everyone.
Figure 15.2 sets the two modes side by side.
Trusted documents
Trusted documents move registration to the build. The client’s build extracts its operations, computes their hashes and publishes a manifest, and the server loads the manifest at start. The server then treats the manifest as the whole truth: it runs documents it lists, by hash or by exact text, and it refuses everything else. The Security page on graphql.org describes the idea in its section “Trusted documents”, and it states the limit: it works when you control the clients, and it cannot protect a public API whose operations are not known in advance.
The example’s manifest, persisted-documents.json, uses Apollo’s persisted-query manifest format: an operations list of id (the SHA-256), name, type and body. It holds three operations, TrackCargo, PortName and RegisterHandling, and verify.sh first checks that each id is the SHA-256 of its body. The two trusted services mount it read-only and read it once at start.
What the trusted instances do, as verify.sh asserts it on 8252 and 8253:
PortNameby hash, never registered, returnsRotterdam, byPOSTand byGET. The manifest is the store.- The text of
PortNamewithout a hash is accepted, because its hash is in the manifest. { __typename }, with or without its hash, answersPERSISTED_QUERY_REQUIREDwith the messageonly persisted documents are accepted, no data and no source call. A registration attempt is refused, not stored: the same hash alone is still unknown afterwards.- An unknown hash is
PERSISTED_QUERY_NOT_FOUND, including a hash another instance registered at run time. { __schema { queryType { name } } }is refused like any other text. Introspection is not special here: it is a document the manifest does not hold. The contract checks therefore run against the APQ instances, which allow introspection, and the README says so.- A mutation in the manifest runs by hash over
POST:RegisterHandlingregistersevt_10. - Text sent another way is held to the same rule. A
queryURL parameter on aPOSTor aGETdoes not replace the document, and on Quarkus anapplication/graphqlbody is checked like a JSONqueryand any other body type gets400. Trusted mode covers every transport the endpoint serves. This example serves HTTP only, so the route refuses any request on/graphqlthat carries anUpgradeheader with400in both modes; Chapter 21 covers WebSockets.
Figure 15.2’s last row is the one that favours trusted mode for caching: a client that cannot register has no registration miss, so a cold cache sees the same short URL as a warm one.
Where the document is resolved
Both stacks need a place to turn a hash into text before the engine looks for it. The example implements Apollo’s protocol in a small piece of code on each stack.
On Strawberry, the HTTP layer accepts a request whose query is null and passes extensions on in the execution context, so an extension can supply the query. SchemaExtension.on_operation runs before Strawberry parses or looks for the text, so the extension sets execution_context.query and the rest of the request proceeds as if the client had sent it. An ExposedError raised before the yield ends the operation with a request error.
On Quarkus, the example resolves the hash one step earlier, in the HTTP router, before SmallRye sees the request.
A Vert.x route registered ahead of SmallRye’s reads the hash, resolves the text, and re-enters SmallRye with rc.reroute(...), passing the text as a query URL parameter. SmallRye accepts URL parameters when http.post.queryparameters.enabled is on, merging them over the body for POST, and reads them for GET when http.get.enabled is on. The route does not touch Vert.x internals. Figure 15.3 puts the two paths side by side.
Both are small, and they are the only pieces of protocol code in the chapter. The Quarkus tab shows the router registration and the branch that resolves a hash alone; the Python tab shows the resolver for every case and the extension that calls it:
void init(@Observes Router router) {
router.route("/graphql").order(-100).handler(PersistedDocumentRoute::refuseUpgrade);
router.route(HttpMethod.POST, "/graphql").order(-10).handler(BodyHandler.create(false));
router.route("/graphql").order(-10).handler(this::resolve);
}
// ... the request has a hash and no text:
String known = manifest.get(hash);
if (known == null && !trusted) {
CompletableFuture<Object> stored = documents.as(CaffeineCache.class).getIfPresent(hash);
known = stored == null ? null : (String) stored.getNow(null);
}
if (known == null) {
refuse(rc, "PERSISTED_QUERY_NOT_FOUND", "PersistedQueryNotFound");
return;
}
proceed(rc, known, true);
def resolve(self, query: str | None, extensions: dict[str, Any] | None) -> str | None:
"""The query text to execute, or an ExposedError that refuses the request."""
persisted = (extensions or {}).get("persistedQuery")
digest = persisted.get("sha256Hash") if isinstance(persisted, dict) else None
if digest is None:
if self.trusted and query is not None and sha256_hex(query) not in self.manifest:
raise PersistedQueryRequired
return query
if query is None:
found = self.lookup(digest)
if found is None:
raise PersistedQueryNotFound
return found
# The server recomputes the hash: a client-supplied one is a claim, not a key.
if sha256_hex(query) != digest:
raise BadUserInput("provided sha does not match query")
if self.trusted:
if digest not in self.manifest:
raise PersistedQueryRequired
else:
self.store[digest] = query
return query
class PersistedQueries(SchemaExtension):
"""Resolve ``extensions.persistedQuery`` before Strawberry looks for the query.
Raising before the ``yield`` ends the operation with a request error and no
data. The exception is an ``ExposedError``, so its message and code reach
the client.
"""
def __init__(self, *, documents: PersistedDocuments) -> None:
super().__init__()
self.documents = documents
def on_operation(self) -> Iterator[None]:
context = self.execution_context
context.query = self.documents.resolve(context.query, context.operation_extensions)
yield
The wiring is the only place the protocol touches anything else. On Quarkus it is configuration, with PersistedDocumentRoute discovered as an ordinary bean. On Python it is two entries in the schema’s extension list, written as factories so that each request gets its own extension instance:
quarkus.smallrye-graphql.http.get.enabled=true
quarkus.smallrye-graphql.http.post.queryparameters.enabled=true
quarkus.cache.caffeine."persisted-documents".maximum-size=1000
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
config=StrawberryConfig(scalar_map=SCALAR_MAP),
extensions=[
partial(MaskErrors, should_mask_error=should_mask, error_message="Internal server error."),
partial(PersistedQueries, documents=documents),
ParserCache,
ValidationCache,
],
)
Three decisions in the Quarkus route keep the checked text and the executed text the same.
- Re-entry is marked in the request context, not by the URL. A client controls the URL, so a
queryparameter cannot be what marks a request as re-entered. The route sets a flag on theRoutingContextbefore it reroutes and trusts only that. Aqueryparameter on a first-hand request is treated as the text, and goes through the same checks. - The text is the URL parameter if present, otherwise the body’s. That follows SmallRye’s own precedence, so the route checks the document that would run.
- A
GETis parsed before it is passed on. The guard in “A mutation stays off GET” needs the operation type of the selected operation, which graphql-java’sParsergives for the cost of a parse. A document that does not parse gets400, and the checked text is what the route sends on. The handler receives exactly the checked text onGETand onPOST.
Storing documents
Each stack keeps its documents in a bounded store, and each engine also keeps a second cache that the store can be mistaken for.
The store maps a hash to text. It is the thing APQ fills and trusted mode never writes. On Quarkus it is a quarkus-cache named persisted-documents, which is Caffeine with the size cap above. On Python it is an LRUCache inside PersistedDocuments, beside the manifest. When it evicts, the handshake repeats.
The parse caches map text to a parsed, validated document, and they exist with or without persisted queries. SmallRye keeps the parsed document in its own QueryCache, which graphql-java consults through its PreparsedDocumentProvider hook, described in the execution page of the graphql-java documentation under “Query Caching”. Strawberry has ParserCache and ValidationCache extensions, each an LRU of 128 entries by default and each passed as a class so that every request gets its own instance. They matter more with persisted queries, because the hash makes every request for a document identical text. Chapter 6 priced parsing and validation per request; here the second request for a document pays neither.
The two layers also fail differently. Losing a stored text costs a round trip. Losing a parsed document costs CPU on the server and nothing on the wire.
Persisted documents and limits
Chapter 14 bounds what one document may do: its depth, its aliases and its cost. A persisted document does not change that analysis, and the two controls cover different attackers.
In trusted mode the limits become a backstop. Every document that can run was written by your developers and reviewed, so the attacks Chapter 14 rejects cannot arrive as new text. A mistake in one of your own documents can still be expensive, and the limits catch it, but they are no longer the main defence.
In APQ mode they are as necessary as before. Anyone can register a document that floods a query with aliases, and the hash does not make it cheaper. The example leaves the limits out so that each chapter shows one concern, and the README says that production carries both.
Operating them
A manifest is a deployment artefact with its own order of operations.
- The manifest deploys before the client. A client that sends a hash the server does not yet hold gets
PersistedQueryNotFound. In APQ mode it recovers by sending the text. In trusted mode there is no recovery, so the new manifest must be live before the build that uses it. - Never remove a hash a live client sends. Old mobile and web builds stay in use for months. Retire a document only when the telemetry says no client sends it.
- Give every document an operation name. Each manifest entry has a
name, and the log or trace of a request by hash would otherwise carry only 64 characters of hex. Chapter 19 builds observability on operation names, and a persisted document is the one place a name is guaranteed to exist. - Treat the hash as part of the contract. Any change to the text, even whitespace, changes the hash. Generate the manifest from the source and never edit a body by hand, and
verify.sh’s manifest check is the same rule enforced.
On the wire
Every value below is asserted by verify.sh, which runs on both stacks. APQ cases use ports 8250 and 8251; trusted cases use 8252 and 8253. H_TRACK, H_PORT and H_REG are the SHA-256 of the manifest bodies.
docker compose -f examples/15-persisted-queries/compose.yaml up -d --build
H=$(printf '%s' '{ cargo(trackingId: "ABC123") { trackingId } }' | sha256sum | cut -d' ' -f1)
# 1. hash only: PersistedQueryNotFound
curl -s localhost:8250/graphql -H 'Content-Type: application/json' \
-d "{\"extensions\":{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"$H\"}}}"
# 2. hash and text: stored and executed
curl -s localhost:8250/graphql -H 'Content-Type: application/json' \
-d "{\"query\":\"{ cargo(trackingId: \\\"ABC123\\\") { trackingId } }\",\"extensions\":{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"$H\"}}}"
# 3. hash only, by GET
curl -s -G localhost:8250/graphql -H 'Accept: application/json' \
--data-urlencode "extensions={\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"$H\"}}"
docker compose -f examples/15-persisted-queries/compose.yaml down -v
Counts and keys do not vary by stack, except where the table says so.
| Check | Quarkus | Python |
|---|---|---|
| manifest | each id equals sha256(body) |
the same |
| handshake 1, hash only | 200, no data; PersistedQueryNotFound, PERSISTED_QUERY_NOT_FOUND; 0 calls |
the same, with "data": null |
| handshake 2, hash and text | ABC123, ONBOARD_CARRIER |
the same |
| handshake 3, hash only | the same data; the same source-call keys as handshake 2 | the same |
| list query by hash | the data of Chapter 13’s four-cargo page; the same calls as its full text | the same |
size, CargoDetail |
exactly 624 bytes with text, exactly 163 by hash | the same |
| mismatch | BAD_USER_INPUT, provided sha does not match query; 0 calls |
the same |
| GET by hash | 200, the same data | the same |
GET by hash with operationName |
200, ABC123 |
the same |
| GET, unknown hash | PersistedQueryNotFound; 0 calls |
the same |
GET and POST, literal % and + |
the source receives A+B%41 C from the text and by hash over GET and over POST, D+E%42 F from a variable, and a 100% document by hash answers 200 |
the same |
GET, operationName that matches nothing |
a single-operation mutation by text and by hash with operationName=Nope: 400, BAD_USER_INPUT; with an empty name: 405; no data, 0 appendEvent |
400, no data, 0 appendEvent |
| GET mutation by hash, and as text | 405, Allow: POST, mutations are not allowed when using GET; 0 appendEvent |
400, the same text; 0 appendEvent |
| GET, malformed variants | a variant of the mutation: 405, nothing written; a document that does not parse: 400, 0 calls |
400, nothing written; a syntax error, 0 calls |
| mutation by hash over POST | evt_10, IN_PORT; the same key by hash again returns evt_10, 0 appendEvent |
the same |
| trusted, manifest hash | Rotterdam by POST and GET |
the same |
| trusted, other text | { __typename }, its hash with it, and introspection: PERSISTED_QUERY_REQUIRED, only persisted documents are accepted, 0 calls |
the same |
| trusted, other transports | an application/graphql introspection body and a ?query= on POST and GET: PERSISTED_QUERY_REQUIRED, 0 calls; an empty application/graphql body: PERSISTED_QUERY_REQUIRED; a WebSocket upgrade, with one Upgrade header or two: 400, 0 appendEvent, 0 calls |
?query= the same; application/graphql is 400 |
| trusted, unknown hash | PERSISTED_QUERY_NOT_FOUND, including a hash registered on the APQ instance |
the same |
| trusted, manifest text and mutation | Rotterdam, also as an application/graphql body; evt_10 by hash |
Rotterdam; evt_10 by hash |
| bounded store | 1,100 registered, exactly 1,000 resolve | the same; the 100 that do not are the first 100 registered |
| schema, on the APQ instance | conforms to contracts/schema/; finalArrivalDate deprecated; only Subscription unserved |
the same |
bash examples/15-persisted-queries/verify.sh runs the whole sequence on both stacks and prints PASS only when both hold.
Decision
Persisted documents answer two axes of Chapter 2 at once. The caching needs axis gets a short, stable GET URL that intermediaries can key, and the operational and security cost axis gets either less parsing and fewer bytes (APQ) or an allowlist (trusted documents). The contract and evolution style axis is the price: a hash is part of the contract between a client build and a server, so manifests deploy before clients and live hashes are never removed. The tracking view therefore runs trusted documents for its own clients, whose operations are known at build time, runs APQ only where clients cannot be enumerated and pairs it with Chapter 14's limits, keeps mutations on POST, and implements Apollo's protocol in a few lines of code on each stack.
Where this goes next
This chapter turned a document into a hash. It walked APQ’s three steps, refused a hash that does not match its text, served persisted queries over GET and kept mutations off it, contrasted APQ with trusted documents, and placed the resolution at the seam where a hash becomes text on each stack.
Chapter 16 builds on the GET URLs this chapter makes: it puts an HTTP cache in front of each stack, sets Cache-Control per response, and caches fields inside the server. The hash-only GET and its Accept header are what that cache keys on.
For the protocol, read Apollo’s Automatic Persisted Queries and the persisted queries and the manifest format in GraphOS. For trusted documents, read the “Trusted documents” section of the Security page on graphql.org. For GET and its rules, read the “GET” section of the GraphQL over HTTP specification. The engine side is in the “Query Caching” section of the graphql-java execution documentation, the Strawberry pages on schema extensions, ParserCache and ValidationCache, and the Quarkus HTTP reference and SmallRye GraphQL guide.