Persisted Queries and Security

BackendGraphQLSecurityAPI
Share on LinkedIn

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:

Rate limiting helps but doesn't stop valid-looking expensive queries.

How persisted queries work

  1. Registration (build time or first request in dev): client sends full query → server stores hash → query mapping
  2. Production: client sends { "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc123..." } }, "variables": { ... } }
  3. 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:

  1. Week 1: Log unknown query hashes, don't reject — build manifest from production traffic
  2. Week 2: Reject unknown hashes in staging, allowlist in prod with monitoring
  3. Week 3: Enforce in prod for web/mobile clients; keep escape hatch for admin tools with introspection
  4. 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

Treat the APQ manifest as a versioned contract — tag releases with manifest hash so support can correlate client crashes with allowed query sets.

Resources

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 →