Skip to main content

Persisted Documents: Or, How to Stop Letting Strangers Write Your GraphQL Queries

· 15 min read
GraphQL Guy

Persisted Documents

Here's a sentence that should terrify anyone running a GraphQL API in production: "Our iOS app sends arbitrary query strings to our server, and we just trust them." You wouldn't let your web app send raw SQL. You wouldn't let a mobile client dictate the shape of a REST response. Yet for some reason, we've collectively decided it's fine to let clients write GraphQL queries of any depth, width, and cost, and we'll just sort it out at runtime with complexity analysis and rate limits. Persisted documents are how mature GraphQL teams fix this, and in 2026 there's finally enough consensus to do it properly.

The Open-Endpoint Problem​

The default GraphQL deployment model is: POST a query string to /graphql, and the server executes it. This is extraordinarily flexible and also, when you think about it for five seconds, extraordinarily scary.

POST /graphql HTTP/1.1
Content-Type: application/json

{
"query": "{ users { id posts { comments { author { friends { posts { comments { author { friends { ... } } } } } } } } } }"
}

That query is syntactically valid. Your schema allows it. Your GraphQL server will try to execute it. By the time query complexity analysis intervenes, the parser has already done work, the resolver graph has been planned, and you've burned real CPU before the rejection. Multiply by 10,000 bot requests per second, and you have an outage.

The Open Endpoint Threat Model
  • Unbounded depth attacks. Nested fragments explode the execution cost.
  • Unbounded breadth attacks. Requesting every field of every type.
  • Introspection abuse. Scraping your entire schema to plan attacks.
  • Data scraping via aliasing. a: user(id: 1) b: user(id: 2) ... returning 10,000 users in one query.
  • Mutation shotgunning. Running 500 mutations in one request.
  • Variable-value bombs. Small query, giant variables payload.
  • Cache poisoning. Slightly different whitespace breaks your query cache.

Query complexity analysis catches a lot of this. Depth limiting catches more. But these are runtime checks. They cost you cycles before they reject.

Persisted documents are a fundamentally different approach: instead of trying to evaluate whether a query is safe at runtime, we only run queries that we've already decided are safe ahead of time. The GraphQL-over-HTTP draft uses the name persisted documents, because a stored GraphQL document can hold mutations and subscriptions as well as queries. Many tools, Apollo's among them, still use the older name, persisted queries.

What Persisted Documents Actually Are​

The idea: the server keeps a registry of approved query documents, each identified by a hash. The client sends the hash instead of the full query text.

POST /graphql HTTP/1.1
Content-Type: application/json

{
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "a3f9e2c8d4..."
}
},
"variables": { "id": "42" }
}

The server looks up the hash, finds the approved query text, executes with the client-supplied variables. If the hash isn't in the registry, the server refuses.

Two Flavors, Different Philosophies

Automatic Persisted Queries (APQ) Client sends the hash; if the server doesn't know it, client sends the full query and server stores it for next time. This saves bandwidth and does not provide any security, because any client can teach the server a new query.

Trusted Documents (aka Safelisted Persisted Queries) Queries are registered at build time from your own code. Unknown hashes are rejected, period. No runtime registration. This is the security model.

APQ is a performance optimization. Trusted documents are a security control. People conflate them constantly. When somebody says "we use persisted queries," ask which flavor they mean.

The Trusted Documents Flow​

Here's what a trusted-documents setup actually looks like end to end:

[Build time]
Your client code has queries: query User { user { id name } }
↓ Build tool (Relay compiler, Apollo codegen, GraphQL Codegen)
Operations extracted and hashed
↓ Upload step
Registry on the server knows about every hash
↓ Deploy

[Runtime]
Client: POST { hash: "a3f9..." }
Server: Looks up hash, finds query, executes, returns data

Key invariant: the server only runs queries that were shipped with a specific client build. Third parties can't invent new queries. Attackers can't construct malicious queries. Even legitimate bugs in unreleased client code can't accidentally hit production because those queries haven't been registered yet.

What This Gets You​

Security Wins
  • No unknown queries. If it's not in the registry, it doesn't run. Full stop.
  • Introspection can be safely disabled in production (clients don't need it, they have generated types).
  • Attack surface reduces to the set of queries you've approved. Ever found a query that opens a weird data leak? Remove it from the registry, push to CDN, done.
  • No runtime query validation pressure. Your server doesn't have to judge queries, it only has to execute approved ones.
Performance Wins
  • No query parsing on hot path. Parsed, validated ASTs can be cached by hash.
  • No query validation on hot path. Validated once at registration, never again.
  • Smaller request payloads. Sending a 64-char hash instead of a 4KB query document.
  • Perfect cache keys. Every query has a stable identifier.
  • Query plan caching hits reliably. The federation router knows exactly what to expect.

The performance wins alone usually justify the work. The security wins are the reason you actually deploy it.

What You Lose​

You can't arbitrarily write new queries from a browser console. You can't easily add a new field to a query without a client rebuild. You can't use GraphiQL against production without an escape hatch. And if your client is third-party (developers integrating with your public API), trusted documents don't apply to them at all.

For first-party apps where you control both ends, these tradeoffs are obvious wins. For public APIs where arbitrary developers write arbitrary queries, trusted documents are genuinely not the right tool.

Setting This Up on Spring GraphQL​

Spring GraphQL doesn't ship a first-class "trusted documents" abstraction yet, but it has the building blocks. The two relevant interfaces:

  • graphql.execution.preparsed.PreparsedDocumentProvider (from graphql-java) - the canonical extension point for caching/reusing parsed documents and for lookup-by-hash patterns.
  • graphql.execution.preparsed.persisted.ApolloPersistedQuerySupport (from graphql-java) - a PreparsedDocumentProvider implementation for the Apollo APQ flow (server-cached, hash-on-second-use). In a Spring GraphQL app you wire it into the GraphQlSource via configureGraphQl(graphQlBuilder -> graphQlBuilder.preparsedDocumentProvider(...)).

For trusted documents (build-time safelisting, not APQ), implement a custom PreparsedDocumentProvider whose getDocumentAsync looks the hash up in your registry and returns the parsed PreparsedDocumentEntry, returning an error entry if the hash isn't known. Don't fall through to executing the raw query - that's the whole point.

Implement getDocumentAsync and not getDocument: the synchronous method is deprecated in graphql-java, and the async one lets you reach a remote registry without blocking the execution thread. One rule comes with the interface: if you do not already hold a parsed document, you must call the supplied parseAndValidateFunction, because it drives the parse and validation instrumentation as well as doing the parsing. The provider below never needs it, since it either has a parsed entry or rejects the request.

@Component
public class TrustedDocumentProvider implements PreparsedDocumentProvider {

private final Map<String, PreparsedDocumentEntry> registry;

public TrustedDocumentProvider(TrustedDocumentRegistry registry) {
this.registry = registry.loadAll(); // hash -> parsed Document entries
}

@Override
public CompletableFuture<PreparsedDocumentEntry> getDocumentAsync(
ExecutionInput input,
Function<ExecutionInput, PreparsedDocumentEntry> parseAndValidate) {
String hash = extractHash(input.getExtensions());
if (hash == null) {
return CompletableFuture.completedFuture(
rejected("Only trusted documents are allowed"));
}
PreparsedDocumentEntry entry = registry.get(hash);
if (entry == null) {
return CompletableFuture.completedFuture(
rejected("Unknown hash: " + hash));
}
return CompletableFuture.completedFuture(entry);
}
}

Wire it into your GraphQlSource:

@Configuration
public class GraphQlConfig {

@Bean
public GraphQlSourceBuilderCustomizer customizer(
TrustedDocumentProvider provider) {
return builder -> builder.configureGraphQl(
graphQlBuilder -> graphQlBuilder.preparsedDocumentProvider(provider));
}
}

In dev, you can layer in a fallback PreparsedDocumentProvider that allows raw queries for debugging; in prod, trusted documents only. The exact PreparsedDocumentEntry "rejection" shape depends on how you want errors to surface - one common pattern is to return an entry whose document is a placeholder that resolves to a GraphQLError during execution.

The Registry: Where Things Get Real​

The interesting engineering question is where the registry lives and how it gets updated.

Option A: Static JSON file baked into the deploy​

@Component
public class TrustedDocumentRegistry {

private final Map<String, PreparsedDocumentEntry> entries;

public TrustedDocumentRegistry(ObjectMapper objectMapper) throws IOException {
ClassPathResource resource =
new ClassPathResource("trusted-documents.json");
Map<String, String> documents = objectMapper.readValue(
resource.getInputStream(),
new TypeReference<Map<String, String>>() {}
);
this.entries = parseAll(documents);
}

// Parse once at startup so the hot path only does a map lookup.
private Map<String, PreparsedDocumentEntry> parseAll(Map<String, String> documents) {
Map<String, PreparsedDocumentEntry> parsed = new HashMap<>();
documents.forEach((hash, text) ->
parsed.put(hash, new PreparsedDocumentEntry(Parser.parse(text))));
return Map.copyOf(parsed);
}

public Map<String, PreparsedDocumentEntry> loadAll() {
return this.entries;
}
}

Note that the parsing happens in the constructor, not in a @PostConstruct method. A final field can only be assigned in a constructor or an initializer, so the @PostConstruct version of this class does not compile. Doing the work in the constructor also means a malformed registry file fails the application at startup instead of at the first request.

The JSON file holds hash to query text. Parsing every document once at startup is what lets the provider return a PreparsedDocumentEntry straight from a map lookup, which is where the "no parsing on the hot path" benefit actually comes from.

Simple. No external dependency. Registry changes require a server redeploy. Works great if clients deploy before servers and both are in your control.

Option B: Database-backed registry​

Registry lives in a table. Your CI pipeline writes new hashes on client build. Server reads at startup and caches.

Adds a DB read, decouples server deploys from client deploys. You need cache invalidation. But it means you can ship a new iOS app without a server deploy.

Option C: Apollo GraphOS PQL​

If you're on Apollo GraphOS, the Persisted Query List is managed for you. Your CI uploads on client build, the router fetches on startup and polls for updates, and you never think about it. Nice if you're already in that ecosystem.

The CI/CD Integration Is The Hard Part

The technical GraphQL work is small. The organizational work is hard:

  • Does your mobile team use the same codegen pipeline as web?
  • Who owns the registry upload step in CI?
  • How do you roll out a new server that knows about v1.8 hashes while v1.7 clients are still in the wild?
  • What happens during deployment windows when both old and new hashes are live?

Your first 80% of the work is getting the infrastructure right. Only then is the runtime enforcement easy.

The Multi-Client Migration Problem​

Here's the operational reality that tutorials skip: your users don't all update at the same time. On day X you deploy v1.8 with new queries. Your server needs to know about v1.8 hashes and v1.7 hashes and v1.6 hashes (where some users are still stuck). If you drop support for v1.6 hashes too early, those users get an error screen.

The right answer: the registry accumulates. Old hashes stay valid as long as clients exist that send them. You expire hashes when you have analytics proving nobody uses them anymore.

This means your registry grows over time. A medium-sized app can have 2,000 registered hashes after two years. That's fine. A hash is 64 chars; a query is a few KB; fitting 2,000 of them in memory is trivial.

What you do not want is "only the most recent version's hashes are valid." That breaks users on stale clients and creates pressure to force-upgrade everyone, which is an ops nightmare for reasons unrelated to GraphQL.

The Public API Case​

If your GraphQL endpoint is public (developer-facing, third-party integrations), trusted documents do not work. You can't register every query an unknown developer might write.

You still have options:

  • API keys with per-key query approval. Enterprise developers submit their queries for review, you approve them into their registry. Works for developer partners, not for self-service.
  • Query complexity limits on the public endpoint. Back to runtime checks, but with teeth. Real cost limits, real rate limits, real depth limits.
  • Separate endpoints for trusted vs. untrusted clients. Your own apps hit /graphql/trusted which requires a persisted document. Partners hit /graphql/public which allows queries under a cost limit.

The third approach is what most hybrid orgs end up on. Your first-party traffic (usually the vast majority) gets the locked-down registry path. Your public API gets more permissive runtime checks. One schema, two security models.

Auto-Persisted Queries (APQ): The Performance Cousin​

Let's quickly clear up the confusion. Automatic Persisted Queries are not a security control. They are a transport optimization.

Client: POST { hash: "a3f9..." }
Server: Don't know that hash, respond with "Please send the query"
Client: POST { hash: "a3f9...", query: "query User { ... }" }
Server: Cache it by hash, execute, respond
Client (next time): POST { hash: "a3f9..." }
Server: Cache hit, execute, respond

The server learns queries from the clients on first use. This makes subsequent requests smaller. It also means any client can teach the server any query.

Be precise about what the attacker can and cannot do here, because the detail is often reported wrongly. An APQ server verifies that the hash it was given really is the SHA-256 of the query text before it stores anything. Apollo's documentation is explicit that "the hash you provide must be the exact SHA-256 hash of the query string," and a mismatch is rejected. So nobody gets to pick a hash like evil123 and bind arbitrary text to it.

What an attacker can do is register any query they like under that query's own correct hash, and then replay it forever. The registry is a content-addressed cache, and it does not decide whether a query is allowed to exist. That is the whole difference between APQ and a safelist.

APQ is fine as bandwidth optimization on internal networks. It is not a substitute for trusted documents. Don't confuse the two. Don't deploy APQ and call it "persisted queries for security."

The State of Standardization​

As of 2026, the de facto interoperability point is the Apollo APQ extension shape: the extensions.persistedQuery object carrying version and sha256Hash. A wide range of clients and servers (GraphQL Yoga, Apollo Router, Cosmo, Hive Gateway, Grafbase, and Spring GraphQL via graphql-java) read and write that shape, so in practice you can mix tools without rewriting the request format.

Formal standardization is less settled. The GraphQL-over-HTTP specification is itself a Stage 2 proposal, and its persisted-documents work is an early-stage appendix instead of a finished, widely-implemented standard. So treat the cross-tool compatibility as a useful convergence on a convention, not as a ratified spec you can fully rely on yet. Check each tool's docs for the exact request shape and error codes before assuming they match.

One detail matters if you are designing a registry today: the draft appendix does not standardize the Apollo shape. It proposes a top-level documentId request parameter instead, described as "an additional optional property documentId which has to be present when query isn't". So the request a spec-conformant server will eventually expect looks like this:

{
"documentId": "a3f9e2c8d4...",
"variables": { "id": "42" }
}

The two shapes carry the same information in different places, and nothing stops a server from reading both. But if you are choosing one now, know that extensions.persistedQuery is where the tools are and documentId is where the specification is heading, and put your hash extraction behind one function so switching later costs you a single edit.

The Honest Adoption Pitch​

Every team that adopts trusted documents says the same thing: "I wish we'd done this on day one." The work is front-loaded (wire up codegen, wire up CI, build the registry-upload step), but the ongoing cost is near zero and the security posture improvement is huge.

Teams that don't adopt trusted documents keep returning to the same class of problem: tuning query complexity analyzers, tightening depth limiters, and handling the cases where someone's client writes a query that DoSs the server. They also spend money on larger servers to absorb the wasted parse/validate cycles.

If you're first-party (your own clients against your own server), trusted documents are close to unambiguously the right answer. If you're public API, you need other controls. Either way, accepting raw unbounded queries from production clients should not be your security model in 2026. We've learned better.


This post was not persisted. Next time, I'll send just the hash.

Sources​