GraphQL Persisted Queries: APQ, Query Whitelisting, and CDN Caching
Implement GraphQL persisted queries for security and performance: Automatic Persisted Queries (APQ), server-side query whitelisting, Relay compiler integration, and CDN caching of GET requests.
GraphQL gives clients enormous flexibility — and that flexibility is also a security risk. Without persisted queries, any client can send an arbitrarily expensive query: allUsers { posts { comments { author { posts { ... } } } } }. A single malicious or misconfigured query can DoS your database.
Persisted queries solve this by pre-registering a whitelist of allowed queries. Clients send a hash instead of the full query text, and the server only executes queries it recognizes. As a bonus, sending a 32-byte hash instead of a 2KB query string dramatically reduces request size — and GET-based hash requests become CDN-cacheable.
The Two Approaches
Automatic Persisted Queries (APQ): Client sends hash first; server fetches the full query from cache if it has it. If not, client resends with full query text. No pre-registration required — queries are registered on first use.
Query Whitelisting: Only pre-registered queries run. Unknown hashes return an error. Maximum security, requires build-time registration.
Most production teams start with APQ and add whitelisting for high-security operations.
Automatic Persisted Queries (APQ) with Apollo Server
// src/graphql/server.ts
import { ApolloServer } from "@apollo/server";
import { ApolloServerPluginLandingPageDisabled } from "@apollo/server/plugin/disabled";
import { createHash } from "crypto";
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
// APQ cache backed by Redis (shared across instances)
const apqCache = {
async get(key: string): Promise<string | undefined> {
const value = await redis.get(`apq:${key}`);
return value ?? undefined;
},
async set(key: string, value: string): Promise<void> {
// Cache queries for 24 hours
await redis.setex(`apq:${key}`, 86400, value);
},
};
export const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
// Disable GraphQL playground in production
process.env.NODE_ENV === "production"
? ApolloServerPluginLandingPageDisabled()
: ApolloServerPluginLandingPageLocalDefault(),
// APQ plugin with Redis persistence
{
async requestDidStart() {
return {
async executionDidStart({ request }) {
// Log query hash for analysis
const hash = request.extensions?.persistedQuery?.sha256Hash;
if (hash) {
await redis.hincrby("apq:stats", hash, 1);
}
},
};
},
},
],
// Pass APQ cache
cache: apqCache as any,
});
Apollo Client APQ Configuration
// src/lib/apollo-client.ts
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";
// APQ link: tries hash first, falls back to full query on cache miss
const persistedQueryLink = createPersistedQueryLink({
sha256,
useGETForHashedQueries: true, // GET requests are CDN-cacheable!
});
const httpLink = new HttpLink({
uri: process.env.NEXT_PUBLIC_GRAPHQL_URL,
credentials: "include",
});
export const apolloClient = new ApolloClient({
link: persistedQueryLink.concat(httpLink),
cache: new InMemoryCache(),
});
Network flow with APQ:
First request (cache miss):
Client → POST {extensions: {persistedQuery: {sha256Hash: "abc123"}}}
Server → 404 {errors: [{message: "PersistedQueryNotFound"}]}
Client → POST {query: "...", extensions: {persistedQuery: {sha256Hash: "abc123"}}}
Server → 200 {data: {...}} // Caches query by hash
Subsequent requests (cache hit):
Client → GET ?extensions={"persistedQuery":{"sha256Hash":"abc123"}}
CDN → 200 {data: {...}} // Served from CDN edge!
🌐 Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 1000+ projects.
- React, Next.js, Node.js, TypeScript — production-grade stack
- Fixed-price contracts — no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Server-Side Query Whitelisting
Whitelisting requires generating a manifest at build time and validating at runtime:
// scripts/generate-query-manifest.ts
// Run during CI — extracts all queries from source files
import { glob } from "glob";
import { readFileSync, writeFileSync } from "fs";
import { createHash } from "crypto";
import { parse, print, DocumentNode } from "graphql";
interface QueryManifest {
version: "1";
generatedAt: string;
queries: Record<string, { query: string; name: string; file: string }>;
}
async function generateManifest(): Promise<void> {
const manifest: QueryManifest = {
version: "1",
generatedAt: new Date().toISOString(),
queries: {},
};
// Find all .ts/.tsx files with GraphQL queries
const files = await glob("src/**/*.{ts,tsx}", { ignore: "node_modules/**" });
for (const file of files) {
const content = readFileSync(file, "utf-8");
// Extract gql`...` template literals
const gqlRegex = /gql`([^`]+)`/g;
let match: RegExpExecArray | null;
while ((match = gqlRegex.exec(content)) !== null) {
try {
const queryText = match[1].trim();
const ast: DocumentNode = parse(queryText);
const normalized = print(ast); // Normalize whitespace
// Generate SHA-256 hash
const hash = createHash("sha256").update(normalized).digest("hex");
const operationName =
ast.definitions[0]?.kind === "OperationDefinition"
? ast.definitions[0].name?.value ?? "anonymous"
: "fragment";
manifest.queries[hash] = {
query: normalized,
name: operationName,
file,
};
} catch (e) {
// Skip invalid GraphQL
}
}
}
writeFileSync(
"dist/query-manifest.json",
JSON.stringify(manifest, null, 2)
);
console.log(`Generated manifest with ${Object.keys(manifest.queries).length} queries`);
}
generateManifest();
Whitelist Enforcement Middleware
// src/graphql/whitelist-plugin.ts
import { ApolloServerPlugin } from "@apollo/server";
import { createHash } from "crypto";
import { readFileSync } from "fs";
interface QueryManifest {
queries: Record<string, { query: string; name: string }>;
}
export function createWhitelistPlugin(options: {
manifestPath: string;
enabled: boolean;
allowIntrospection?: boolean;
}): ApolloServerPlugin {
if (!options.enabled) {
return {}; // No-op in development
}
const manifest: QueryManifest = JSON.parse(
readFileSync(options.manifestPath, "utf-8")
);
const allowedHashes = new Set(Object.keys(manifest.queries));
return {
async requestDidStart() {
return {
async didResolveOperation({ request, document, operationName }) {
// Allow introspection queries in development
if (
options.allowIntrospection &&
operationName === "IntrospectionQuery"
) {
return;
}
const queryHash = request.extensions?.persistedQuery?.sha256Hash;
// If hash provided, verify it's in whitelist
if (queryHash) {
if (!allowedHashes.has(queryHash)) {
throw new GraphQLError("Query not found in whitelist", {
extensions: { code: "PERSISTED_QUERY_NOT_ALLOWED" },
});
}
return;
}
// If no hash, compute and check
if (request.query) {
const computedHash = createHash("sha256")
.update(request.query.trim())
.digest("hex");
if (!allowedHashes.has(computedHash)) {
throw new GraphQLError(
"Arbitrary queries are not allowed. Use persisted queries.",
{ extensions: { code: "QUERY_NOT_WHITELISTED" } }
);
}
}
},
};
},
};
}
Relay Compiler Integration
Relay's compiler generates persisted query manifests automatically, making whitelisting trivial for Relay-based apps:
// relay.config.json
{
"src": "./src",
"schema": "./schema.graphql",
"language": "typescript",
"persistConfig": {
"file": "./dist/relay-query-manifest.json",
"algorithm": "SHA256"
},
"eagerEsModules": true,
"customScalars": {
"DateTime": "string",
"JSON": "Record<string, unknown>"
}
}
# Run Relay compiler during build
> **Quick answer.** Persisted queries pre-register a whitelist of allowed GraphQL operations so clients send a small hash instead of full query text, blocking arbitrarily expensive queries that could DoS your database. With Automatic Persisted Queries (APQ) no pre-registration is needed, and GET-based hash requests become CDN-cacheable.
npx relay-compiler
# This generates:
# 1. __generated__/*.graphql.ts files with typed query artifacts
# 2. dist/relay-query-manifest.json with all query hashes
// Generated artifact — Relay compiler output
// src/__generated__/UserProfileQuery.graphql.ts
import { ConcreteRequest } from "relay-runtime";
const node: ConcreteRequest = {
kind: "Request",
fragment: { /* ... */ },
operation: { /* ... */ },
params: {
// Hash used for APQ — Relay handles this automatically
id: "a3f5b89c4d2e1f6a7b8c9d0e1f2a3b4c",
metadata: {},
name: "UserProfileQuery",
operationKind: "query",
text: null, // null = always use persisted query
},
};
export default node;

🚀 Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4–8 weeks, full platforms in 3–5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
CDN Caching for GET-Based Queries
Hashed GET requests are cacheable at the CDN layer — a major performance win for public data:
// src/app/api/graphql/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const extensionsParam = searchParams.get("extensions");
if (!extensionsParam) {
return NextResponse.json({ error: "extensions required" }, { status: 400 });
}
const extensions = JSON.parse(decodeURIComponent(extensionsParam));
const hash = extensions?.persistedQuery?.sha256Hash;
if (!hash) {
return NextResponse.json({ error: "persistedQuery hash required" }, { status: 400 });
}
// Look up query from APQ cache
const queryText = await apqCache.get(hash);
if (!queryText) {
return NextResponse.json(
{ errors: [{ message: "PersistedQueryNotFound", extensions: { code: "PERSISTED_QUERY_NOT_FOUND" } }] },
{ status: 200 } // Return 200 with error — Apollo client handles this
);
}
// Execute query
const result = await executeGraphQLQuery(queryText, {
variables: searchParams.get("variables")
? JSON.parse(decodeURIComponent(searchParams.get("variables")!))
: {},
});
// Determine cache TTL based on query type
const cacheControl = getCacheControl(hash, result);
return NextResponse.json(result, {
headers: {
"Cache-Control": cacheControl,
"CDN-Cache-Control": cacheControl,
"Surrogate-Key": `gql-${hash.slice(0, 8)}`, // For targeted purging
},
});
}
function getCacheControl(hash: string, result: unknown): string {
// Check if result contains user-specific data
if (isPersonalized(result)) {
return "private, no-store";
}
// Public data: cache at CDN for 60 seconds, stale-while-revalidate 300s
return "public, s-maxage=60, stale-while-revalidate=300";
}
// POST for mutations and cache-miss fallback
export async function POST(request: NextRequest) {
const body = await request.json();
const result = await executeGraphQLQuery(body.query, body);
return NextResponse.json(result, {
headers: {
"Cache-Control": "no-store", // Mutations never cached
},
});
}
Query Complexity Analysis
Whitelisting prevents unknown queries, but whitelisted queries can still be expensive. Add complexity limits:
// src/graphql/complexity-plugin.ts
import { getComplexity, simpleEstimator, fieldExtensionsEstimator } from "graphql-query-complexity";
import { ApolloServerPlugin } from "@apollo/server";
export function createComplexityPlugin(options: {
maxComplexity: number;
schema: GraphQLSchema;
}): ApolloServerPlugin {
return {
async requestDidStart() {
return {
async didResolveOperation({ request, document }) {
const complexity = getComplexity({
schema: options.schema,
operationName: request.operationName,
query: document!,
variables: request.variables,
estimators: [
// Field-level cost defined in schema extensions
fieldExtensionsEstimator(),
// Default cost: 1 per field, multiplied by pagination count
simpleEstimator({
defaultComplexity: 1,
}),
],
});
if (complexity > options.maxComplexity) {
throw new GraphQLError(
`Query complexity ${complexity} exceeds maximum ${options.maxComplexity}`,
{
extensions: {
code: "QUERY_TOO_COMPLEX",
complexity,
maxComplexity: options.maxComplexity,
},
}
);
}
// Add complexity to response extensions for monitoring
request.extensions = {
...request.extensions,
queryComplexity: complexity,
};
},
};
},
};
}
// Schema with complexity hints
const typeDefs = gql`
type Query {
users(first: Int = 10): UserConnection @complexity(value: 5, multipliers: ["first"])
user(id: ID!): User @complexity(value: 1)
}
type User {
posts(first: Int = 10): PostConnection @complexity(value: 5, multipliers: ["first"])
# Expensive: hits external service
recommendations: [Product] @complexity(value: 20)
}
`;
Performance Impact Reference
| Scenario | Without APQ | With APQ (GET) | With CDN Cache |
|---|---|---|---|
| Query payload size | 2–50KB | 64 bytes (hash) | 0 bytes (cached) |
| Server CPU per request | Full parse + validate | Hash lookup only | 0 (CDN hit) |
| TTFB for cached queries | 100–500ms | 100–500ms | 5–15ms |
| Bandwidth (1M req/day) | 2–50GB | ~64MB | Minimal |
Related Reading
- GraphQL Federation — distributed GraphQL schema
- GraphQL Subscriptions — real-time GraphQL
- API Gateway Patterns — gateway-level caching
- API Security Best Practices — query depth limiting
Our Capabilities
We design and implement GraphQL APIs with production-grade security from the start: persisted query whitelisting, complexity analysis, depth limiting, and CDN-cacheable query architecture. Our clients have reduced GraphQL payload sizes by 95% and eliminated query-based DoS vectors.
External Resources
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
Need a Modern Web Application?
From landing pages to complex SaaS platforms — we build it all with Next.js and React.
Free consultation • No commitment • Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.