Persisted Queries and Security
A public GraphQL endpoint without query restrictions is an open invitation. Anyone can POST a megabyte query with nested fragments 15 levels deep and melt your database. Persisted queries don't fix every GraphQL security concern, but they remove the biggest one: arbitrary query execution. Once we switched to hash-only production traffic, our p99 latency dropped and the weird 3 AM query spikes stopped entirely.
The attack surface without restrictions
Unrestricted GraphQL accepts any query string:
query {
users {
posts {
comments {
author {
posts {
comments { author { email } }
}
}
}
}
}
}
One request, exponential resolver fan-out. Other common abuses:
- Introspection scraping — mapping your entire schema
- Batching attacks — array of queries in one HTTP request
- Field suggestion probing — guessing field names via error messages
Rate limiting helps but doesn't stop valid-looking expensive queries.
How persisted queries work
- Registration (build time or first request in dev): client sends full query → server stores
hash → querymapping - Production: client sends
{ "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc123..." } }, "variables": { ... } } - Server: looks up hash in registry → executes known query → rejects unknown hashes with 403 or 400
No query text crosses the wire in production.
Apollo Automatic Persisted Queries (APQ)
Apollo Client supports APQ out of the box:
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
import { persistQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const link = persistQueryLink({ sha256, useGETForHashedQueries: true })
.concat(new HttpLink({ uri: '/graphql' }));
const client = new ApolloClient({ link, cache: new InMemoryCache() });
Server-side (Apollo Server):
const { ApolloServerPluginPersistedQueries } = require('@apollo/server-plugin-persisted-queries');
const { KeyvAdapter } = require('@apollo/utils.keyvadapter');
const Keyv = require('keyv');
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginPersistedQueries({
cache: new KeyvAdapter(new Keyv({ namespace: 'apq' })),
}),
],
persistedQueries: {
ttl: null, // persist indefinitely once registered
},
});
For production, disable runtime registration — only accept pre-registered hashes from a manifest generated at build time.
Strict whitelist at deploy time
The safer production pattern:
// queries/manifest.json — generated by graphql-codegen or a build script
{
"a1b2c3...": "query GetUser($id: ID!) { user(id: $id) { name email } }",
"d4e5f6...": "query ListPosts($first: Int!) { posts(first: $first) { edges { node { title } } } }"
}
Server middleware:
function persistedQueryMiddleware(manifest) {
return (req, res, next) => {
const hash = req.body?.extensions?.persistedQuery?.sha256Hash;
const query = manifest[hash];
if (!query) {
return res.status(403).json({ errors: [{ message: 'PersistedQueryNotFound' }] });
}
req.body.query = query;
next();
};
}
Deploy the manifest with the server. New queries require a deploy — intentional friction.
Build-time manifest generation
Use @graphql-codegen/cli with the persisted-documents plugin:
# codegen.yml
generates:
./generated/persisted-documents.json:
plugins:
- graphql-codegen-persisted-query-ids
Or a simple build script:
#!/bin/bash
for f in src/queries/*.graphql; do
hash=$(sha256sum "$f" | cut -d' ' -f1)
echo "\"$hash\": $(jq -Rs . < "$f"),"
done
CI validates that every client query hash exists in the server manifest.
Layered hardening
Persisted queries are one layer. Combine with:
| Control | Purpose |
|---|---|
| Disable introspection in prod | ApolloServerPluginLandingPageDisabled() + custom validation rule |
| Query depth limiting | Reject queries deeper than 10 levels |
| Query cost analysis | Assign costs per field, reject above budget |
| Rate limiting per operation | Different limits for expensive vs cheap queries |
| GET-only for hashed queries | Enables CDN caching for read operations |
import { depthLimit } from '@graphile/depth-limit';
const server = new ApolloServer({
validationRules: [depthLimit(10)],
introspection: process.env.NODE_ENV !== 'production',
});
CDN caching bonus
When hashed queries use GET requests (?extensions=...&variables=...), responses cache at the edge. A read-heavy API can serve most traffic from CloudFront or Fastly without hitting origin. This is a performance win on top of the security win.
APQ rollout strategy
Don't flip persisted-queries-only overnight:
- Week 1: Log unknown query hashes, don't reject — build manifest from production traffic
- Week 2: Reject unknown hashes in staging, allowlist in prod with monitoring
- Week 3: Enforce in prod for web/mobile clients; keep escape hatch for admin tools with introspection
- Ongoing: CI blocks deploy if client ships hash not in server manifest
Mobile apps lag server deploys by weeks — support N-1 manifest versions or clients break on forced update.
Attack scenarios persisted queries prevent
| Attack | Without APQ | With APQ |
|---|---|---|
| Arbitrary query batching | Attacker sends 50 mutations in one request | Only registered hashes accepted |
| Deep introspection scrape | Full schema dump | Introspection disabled + no ad-hoc queries |
| Resource exhaustion | { users { friends { friends { ... }}}} |
Depth/cost limits + known query set |
| Field suggestion probing | Trial-and-error field names | 403 on unknown hash |
APQ doesn't replace auth — authenticated users can still abuse allowed expensive queries. Combine with per-operation rate limits.
Client implementation notes
Apollo Client automatic persisted queries:
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const link = createPersistedQueryLink({ sha256 });
First request sends full query; server stores hash. Subsequent requests send hash only. Handle PersistedQueryNotFound by retrying with full query body once.
Pair with GraphQL pagination patterns — cursor queries are ideal APQ candidates because they're repeated identically.
Production checklist
- Unknown query hashes logged before enforcement
- CI validates client hashes against server manifest
- Depth and cost limits combined with APQ
- Introspection disabled in production
- N-1 manifest versions supported for mobile lag
Treat the APQ manifest as a versioned contract — tag releases with manifest hash so support can correlate client crashes with allowed query sets.
Resources
- Apollo Persisted Queries documentation — APQ setup for Apollo Server and Client
- GraphQL over HTTP — persisted queries — spec for APQ over HTTP
- GraphQL Armor — depth/cost limiting plugins for multiple servers
- Shopify — GraphQL security in production — operational patterns at scale
Frequently asked questions
What are GraphQL persisted queries?
Persisted queries map a hash (typically SHA-256 of the query string) to a pre-registered operation stored server-side. Clients send only the hash plus variables, not the full query text. The server looks up the operation by hash and executes it. Unknown hashes are rejected.
How do persisted queries improve security?
They eliminate ad-hoc query execution in production — attackers can't send arbitrary deep queries, introspection probes, or field-suggestion attacks. Combined with disabling introspection in production, the attack surface shrinks to a known set of operations you've reviewed.
What is the difference between APQ and a strict query whitelist?
Automatic Persisted Queries (APQ) allow clients to register queries on first use (often dev/staging only), then persist them for production. A strict whitelist pre-registers all queries at deploy time with no runtime registration. Production APIs should use strict whitelists; APQ is a convenience during development.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →