Topic: GraphQL persisted query security
MCP server GraphQL persisted query security
GraphQL's flexibility is also its attack surface: without an operation allowlist, any caller can send arbitrary queries to your MCP server — including deeply nested queries that exhaust compute, introspection queries that map your schema, or mutations you never intended to expose publicly. Persisted queries and APQ address this, but only if implemented correctly.
Why arbitrary GraphQL queries are a security risk for MCP servers
A REST endpoint exposes exactly the fields its handler returns. A GraphQL endpoint exposes every field reachable from the query root unless you explicitly prevent it. An attacker — or an LLM agent following a prompt injection — can issue queries your frontend never sends:
# Schema-discovery query — reveals all types and fields
{
__schema {
types {
name
fields { name type { name kind ofType { name } } }
}
}
}
# Deep nesting attack — O(n^depth) resolver calls
{
user {
friends {
friends {
friends {
friends { id email }
}
}
}
}
}
The introspection query is enabled by default in most GraphQL servers and exposes your entire data model to any caller. The nested query can exhaust resolver concurrency limits before a depth limit triggers. An operation allowlist stops both attacks by refusing any query not in a pre-approved set — schema discovery and synthetic nested queries are never in the allowlist because no legitimate client ever sends them.
Disabling introspection in production is straightforward but remember that field enumeration is still possible without it: an attacker who knows common GraphQL patterns can probe for fields by name and infer the schema from error messages. Schema-aware fuzzing tools like clairvoyance reconstruct a significant portion of a GraphQL schema purely from 404/200 response patterns, without any introspection access. An operation allowlist defeats this too — field enumeration probes generate queries that are not in the allowlist and are rejected before execution, producing no signal the attacker can use.
MCP servers that expose GraphQL are particularly at risk because LLM agents can generate novel GraphQL queries dynamically. A prompt injection attack that tells the agent "use the GraphQL API to list all users" can result in an arbitrary query being sent to the server — not a query that was in any client's source code, but one generated at runtime by the model. An operation allowlist breaks this attack class entirely: a dynamically generated query will not match any hash in the allowlist, and the server rejects it before any resolver runs. Without an allowlist, the entire server's data model is accessible to any client — including an LLM agent that has been hijacked via prompt injection.
Implementing a hash-based operation allowlist
At build time, the client's bundler extracts every GraphQL operation (query or mutation) from the source code, normalizes whitespace, and computes the SHA-256 hash. These hashes and their corresponding query bodies are stored in allowlist.json and committed to the repository. The server reads this file at startup.
At runtime the client sends only the hash (and optionally the query body for debugging). The server looks up the hash in the allowlist. If the hash is not present, the request is rejected immediately — no parsing, no execution.
// server/graphql-allowlist.ts
import { createHash } from 'node:crypto';
import allowlist from '../allowlist.json' assert { type: 'json' };
// allowlist.json shape: { [sha256hex]: string (query body) }
const allowlistSet = new Set(Object.keys(allowlist));
export function allowlistPlugin() {
return {
requestDidStart() {
return {
async didResolveOperation({ request, document }) {
const hash = request.extensions?.persistedQuery?.sha256Hash;
if (!hash) {
throw new GraphQLError('PersistedQueryOnly: send hash via extensions.persistedQuery.sha256Hash', {
extensions: { code: 'PERSISTED_QUERY_ONLY' },
});
}
if (!allowlistSet.has(hash)) {
throw new GraphQLError('PersistedQueryNotFound', {
extensions: { code: 'PERSISTED_QUERY_NOT_FOUND' },
});
}
// If query body was also sent, verify it matches the allowlisted body
if (request.query) {
const bodyHash = createHash('sha256').update(request.query).digest('hex');
if (bodyHash !== hash) {
throw new GraphQLError('PersistedQueryHashMismatch', {
extensions: { code: 'PERSISTED_QUERY_HASH_MISMATCH' },
});
}
}
},
};
},
};
}
The hash mismatch check on the last branch is critical: without it, a client can send a valid allowlisted hash alongside a different query body, and the server will execute the arbitrary query using the allowlisted hash as cover.
Store the allowlist in memory at server startup, not in a database. The allowlist is a small static file (typically a few hundred entries totalling less than 100 KB) that changes only on deployment. Loading it into a Map or Set in process memory gives O(1) hash lookup with zero I/O overhead per request. If the allowlist is stored in a database or Redis and that store is unavailable, every GraphQL request fails — an unacceptable availability tradeoff. The allowlist is a code artifact; treat it like code and ship it with the server binary, not as a runtime dependency.
Log every allowlist miss with the attempted hash value (truncated to 16 hex characters to avoid storing full attacker input) and the client IP. Aggregate these logs daily: a client IP that generates hundreds of allowlist misses is likely an attacker probing for valid hashes or testing whether the allowlist is enforced. Add rate limiting on allowlist-miss responses — return 429 after 10 misses per minute from the same IP — to slow probing without impacting legitimate clients who send valid hashes.
APQ security tradeoffs: bandwidth optimization, not a security gate
Apollo's Automatic Persisted Queries (APQ) use a two-phase protocol: the client sends only the hash; on a cache miss the server returns PersistedQueryNotFound; the client resends with the full query body; the server registers the hash and executes. APQ reduces bandwidth for repeat queries.
The security implication is fundamental: APQ is not an allowlist. On the first request for any hash, APQ passes the full query body through and executes it. An attacker simply sends a novel hash + arbitrary query body — it is registered and executed on the first request. APQ provides zero protection against arbitrary queries unless you pair it with independent complexity and depth limits.
// Use APQ for bandwidth only — pair with hard limits, not as a security gate
const server = new ApolloServer({
plugins: [ApolloServerPluginAPQ()],
validationRules: [
depthLimitRule(6), // reject queries nested deeper than 6
createComplexityLimitRule(1000), // reject queries with complexity > 1000
],
});
// For a true security gate, replace APQ with the allowlistPlugin() above
Rule of thumb: use APQ when your clients are known and trusted and you want to save bandwidth. Use an operation allowlist when your GraphQL endpoint is public or accessible to LLM agents that may be prompt-injected.
If you are migrating from APQ to a strict allowlist, the transition can be done in two phases without downtime. Phase 1: run both APQ and the allowlist in audit mode — log every APQ miss (first-time query registrations) and every allowlist miss (hashes not found in the allowlist), but allow both through. Collect the set of hashes that appear in APQ logs but not in the committed allowlist — these are queries being sent by clients that were never captured during the build. Phase 2: add those hashes to the allowlist after review, then switch the allowlist from audit mode to enforcement mode. After enforcement is live, disable APQ registration (only allow known hashes, reject new ones). The transition takes one sprint and produces a complete, validated allowlist without any client-visible downtime.
Allowlist management in CI: extract, hash, and enforce
An allowlist only protects if it stays in sync with the client code. The correct workflow: a graphql-codegen plugin extracts all operations from client source at build time, hashes them, and writes allowlist.json. CI enforces that the committed file matches the extracted result.
# codegen.ts (graphql-codegen config)
import { CodegenConfig } from '@graphql-codegen/cli';
export default {
generates: {
'./src/__generated__/types.ts': { plugins: ['typescript', 'typescript-operations'] },
'./allowlist.json': {
plugins: ['graphql-codegen-persisted-query-ids'],
config: { algorithm: 'sha256', output: 'server' },
},
},
} satisfies CodegenConfig;
# CI step — fail if allowlist.json is out of date
- name: Verify allowlist is committed
run: |
npx graphql-codegen
if ! git diff --exit-code allowlist.json; then
echo "allowlist.json is out of date. Run 'npx graphql-codegen' and commit the result."
exit 1
fi
This means a developer cannot add a new query to the client without regenerating the allowlist and committing the updated file. The CI check prevents drift between what the client sends and what the server accepts. Any PR that introduces a new operation but forgets to update allowlist.json fails before merge.
For multi-client MCP deployments (mobile app, web app, CLI client, and automated agent clients all sharing one GraphQL endpoint), maintain a single unified allowlist that is the union of all clients' operations. Each client should only send hashes that correspond to its own operations — but the server does not need to enforce which client sends which hash. The important invariant is that every hash in the allowlist corresponds to a query that was reviewed and approved at build time by a developer. No runtime-generated query can appear in the allowlist because the allowlist is a static artifact built from source code.
Version the allowlist alongside your API schema in your GraphQL contract test suite. When a schema change removes a field that an allowlisted query selects, the contract test should fail — warning you that deployed clients are sending queries that will now return partial results or errors. This catches breaking changes before they reach production and gives you time to update both the client query and the allowlist entry together. The allowlist becomes a living contract between your clients and your server, not just a security gate.
Introspection control and schema exposure
An operation allowlist prevents arbitrary query execution, but introspection is a separate concern: it is a built-in GraphQL query (__schema, __type) that reveals your entire data model. Even with an operation allowlist, if introspection is not in the allowlist, it should be rejected. Most production GraphQL servers should disable introspection entirely in production and re-enable it only in non-public staging environments.
// Disable introspection in production
import { NoSchemaIntrospectionCustomRule } from 'graphql';
const server = new ApolloServer({
schema,
validationRules: process.env.NODE_ENV === 'production'
? [NoSchemaIntrospectionCustomRule]
: [],
// ...allowlistPlugin() from section 2
plugins: [allowlistPlugin()],
});
The NoSchemaIntrospectionCustomRule rejects any operation containing __schema or __type field selections. It runs as a validation rule before execution, so it adds negligible overhead. Pair it with the allowlist: even if the allowlist check is bypassed (e.g., through a misconfigured plugin ordering), introspection is rejected at the validation layer.
For MCP servers that expose GraphQL to LLM agents, schema exposure is especially dangerous. An agent that can introspect your schema can construct sophisticated queries that extract data beyond what the agent's tool definitions suggest it can access. The combination of schema knowledge and the agent's ability to dynamically generate queries creates an IDOR surface that is difficult to enumerate manually — SkillAudit's static analysis checks for introspection-enabled MCP endpoints automatically.
Query complexity and depth limits as a defence-in-depth layer
An operation allowlist is the primary security control. Complexity and depth limits are secondary controls that protect against two failure modes: (1) a legitimate allowlisted query that turns out to be more expensive than expected due to schema changes (e.g., a field that previously resolved a scalar now resolves a list of objects), and (2) an allowlist hash collision or bypass that permits an unexpected query through.
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(7), // max 7 levels of nesting
createComplexityLimitRule(2000, { // max complexity score of 2000
scalarCost: 1,
objectCost: 2,
listFactor: 10, // lists multiply complexity by 10
}),
],
plugins: [allowlistPlugin()],
});
Set depth and complexity limits based on the most expensive legitimate query in your allowlist, plus a 50% headroom. This prevents legitimate queries from failing while ensuring synthetic queries (which are typically much deeper or more complex than anything a real client sends) are rejected before they touch resolvers.
Measure the complexity of each query in your allowlist using the same complexity formula during CI: if a new query's complexity exceeds 80% of the limit, fail CI and require a deliberate limit increase. This prevents allowlist complexity creep where the legitimate query set gradually approaches the limit until there is no headroom left for the defence-in-depth layer to matter.
Expose complexity metrics in your GraphQL server logs: log the computed complexity score for every executed query (even those in the allowlist) along with the operation name and the user's tenant ID. This gives you two benefits: (1) you can track which operations are most expensive and optimize resolvers or add DataLoader batching before they become a performance problem; (2) if a breach or bug ever allows an unexpected query through, the complexity log shows exactly what was executed and how expensive it was, which is invaluable for post-incident forensics.
For MCP servers that serve automated agent clients (not interactive users), set tighter complexity limits than for interactive clients. An LLM agent making tool calls rarely needs the same depth of data as a human browsing a dashboard. Separate the limits by OAuth scope: queries made with agent:tools scope are limited to complexity 500; queries made with user:dashboard scope are limited to 2000. This ensures that a compromised agent cannot exhaust your GraphQL server's compute budget even if its queries somehow bypass the allowlist check.
SkillAudit findings for GraphQL persisted query security
Run a SkillAudit scan to detect GraphQL operation allowlist gaps in your MCP server. See also MCP server GraphQL security.