Back to Blog

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.

Viprasol Tech Team
September 14, 2026
13 min read

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

ScenarioWithout APQWith APQ (GET)With CDN Cache
Query payload size2โ€“50KB64 bytes (hash)0 bytes (cached)
Server CPU per requestFull parse + validateHash lookup only0 (CDN hit)
TTFB for cached queries100โ€“500ms100โ€“500ms5โ€“15ms
Bandwidth (1M req/day)2โ€“50GB~64MBMinimal

See Also


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.

Web development services โ†’ | Talk to our team โ†’

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol ยท Web Development

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.