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 100+ 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
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 |
See Also
- GraphQL Federation โ distributed GraphQL schema
- GraphQL Subscriptions โ real-time GraphQL
- API Gateway Patterns โ gateway-level caching
- API Security Best Practices โ query depth limiting
Working With Viprasol
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.
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 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
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.