Skip to content

Migrating to trusted documents

GraphQL only A plain HTTP API has no operations to register; its paths are the allowlist.

With trusted documents, your service runs only the operations you registered, and refuses every other one, however valid. An attacker cannot craft a new query, and you know exactly what runs against your API. Switching it on in one step breaks every client that sends an operation you forgot. This guide gets there in stages, so the switch happens against evidence. How persisted queries work is on persisted queries.

The running example is a web app built with graphql-codegen, which writes dist/persisted-documents.json, and a mobile app released every two weeks.

  • Clients that send the Apollo-style extensions.persistedQuery hash; see how a client sends one.
  • A manifest from your client build, and a CI job that can run gpilot pq push; see deploy from CI.

1. Push the manifest before every client release

Section titled “1. Push the manifest before every client release”

Register the operations of each client build before that build reaches users:

Terminal window
gpilot pq push ./dist/persisted-documents.json --dry-run # parse and verify only
gpilot pq push ./dist/persisted-documents.json

A push only adds, so the operations of a release still in the field keep working. Every entry is checked: its id must be the SHA-256 of its document, and a mismatch is reported with the one configuration line that fixes it. Formats and flags are on registering your operations.

Let everything run, and record what is not registered:

gpilot.toml
[persisted_query]
preset = "trusted_migrating"

That preset resolves hashes, lets clients register operations through the APQ handshake, and logs every operation the registry does not hold as PERSISTED_QUERY_UNTRUSTED_OPERATION, with its hash and operation name. Nothing is refused yet. If clients should not register anything while you watch, turn the handshake off beside the preset:

gpilot.toml
[persisted_query]
preset = "trusted_migrating"
apq = false

Deploy with gpilot deploy.

Two places show what trusted would refuse:

  • The edge codes. Each request that ran an unregistered operation carries PERSISTED_QUERY_UNTRUSTED_OPERATION under Edge codes in its Overview tab in Insights.
  • The APQ list. gpilot apq ls, or the APQ tab on the portal’s Queries page, lists the operations clients registered themselves that your manifest does not declare. Read one with gpilot apq show <hash>.

For each one, find the client code that sends it and add it to that client’s manifest, or decide it should stop running. Keep watching until a full release cycle passes with nothing new; for the mobile app, that means until the oldest version still in use has sent everything it sends.

Push the updated manifests, then remove what clients registered on their own:

Terminal window
gpilot pq push ./dist/persisted-documents.json
gpilot apq rm --all

apq rm revokes those entries and purges everything cached for them.

gpilot.toml
[persisted_query]
preset = "trusted"

trusted resolves hashes, refuses the handshake and refuses anything the registry does not hold. Deploy.

A registered operation still answers:

Terminal window
curl -sS "https://$GRAPHPILOT_HOST/graphql" -H 'content-type: application/json' \
-d '{"extensions":{"persistedQuery":{"version":1,"sha256Hash":"<a hash from your manifest>"}}}'

A query nobody registered is refused with 403 and FORBIDDEN:

Terminal window
curl -sS -o/dev/null -w '%{http_code}\n' "https://$GRAPHPILOT_HOST/graphql" \
-H 'content-type: application/json' -d '{"query":"{ __typename }"}'

The refused request’s log line is PERSISTED_QUERY_REFUSED. Every answer the edge can give is on codes.

  • Cached responses outlive the switch. The allowlist is checked on a miss, not on a hit, so revoke with gpilot pq rm or gpilot apq rm, which purge, what must stop at once; see choosing a setup.
  • only_registered and apq together are refused at deploy time. An allowlist anyone can add to is not one. trusted already has apq off; do not turn it back on.
  • Ad-hoc queries stop too. Your own tooling, a GraphiQL session and a partner’s scripts are refused unless their operations are registered. Put them on a separate service if they need to stay open.
  • A client released before its push sends hashes the registry cannot resolve, and under trusted that is a 403. Push in the same pipeline, before the release.
  • Subscriptions are not covered. Operations sent over a WebSocket travel inside the tunnel, where the edge never reads them; see subscriptions and WebSockets.
  • A client profile can require hashes. A client profile with persisted_query = "required" refuses the APQ handshake too, so its operations have to be pushed; see telling your clients apart.