GraphQL Persisted Mutations: Security for Write Operations
AI generated
{ }
type
GraphQL · Security · Mutations · API Protection
GraphQL Persisted Mutations
Security for write operations

A publicly reachable GraphQL endpoint that accepts arbitrary mutations is an open door for data manipulation, spam, and automated attacks. GraphQL persisted mutations close this gap by only allowing execution of a server-side pre-approved list of operations, everything else gets rejected before a single resolver ever runs.

17 min read Persisted Mutations · Allowlist · Manifest · CI/CD GraphQL Security

1. The security problem with write GraphQL operations

Read operations in GraphQL are risky because they can expose data, write operations are risky because they change the state of the system. A public GraphQL endpoint with free-form mutation execution allows anyone who knows the schema structure, or discovers it through introspection, to submit arbitrary mutations with arbitrary arguments. GraphQL persisted mutations address exactly this problem, the client no longer sends free-form query syntax to the server, only a hash pointing to a server-registered, precisely vetted operation.

The difference from classic input validation is fundamental: input validation checks the values inside a mutation, but does not prevent an unknown or maliciously crafted mutation from being executed at all. GraphQL persisted mutations act one level earlier, they prevent the execution of any operation that does not exactly match an approved mutation shipped by your own frontend build. An attacker attempting to submit a mutation that is technically valid in the schema but was never used in the frontend, say deleteAllOrders without a permission check in the resolver, already fails on the missing hash in the allowlist.

This pattern is particularly critical for GraphQL APIs publicly reachable from mobile apps or single-page applications. There, an API key or auth token can be extracted through reverse engineering, but without GraphQL persisted mutations the query structure itself remains entirely free to construct.

2. Persisted queries vs. persisted mutations

Persisted queries are already well established in the GraphQL community, primarily as a performance optimization that shrinks the query payload in the request down to a short hash. GraphQL persisted mutations use the same technical pattern but pursue a different primary goal: not saving bandwidth, but minimizing attack surface. The difference shows up in error handling: for plain persisted queries, an unknown hash is usually a harmless miss, the frontend simply sends the full query afterward. For persisted mutations, an unknown hash must always result in outright rejection, falling back to free mutation syntax would negate the entire protection.

This distinction is why many teams treat queries and mutations differently: persisted queries with fallback for better performance, persisted mutations with no fallback as a hard security boundary. Anyone introducing GraphQL persisted mutations should encode this distinction explicitly in the server code, rather than treating both operation types through the same mechanism.

3. Hash-based operation IDs: how they work

The core of GraphQL persisted mutations is a deterministic hash over the normalized operation text, usually SHA-256. During the frontend build, every mutation used is extracted, its hash computed, and stored in a manifest. At runtime the client sends only this hash together with the variables instead of the full mutation, the server looks it up in the manifest, finds the associated, pre-vetted operation, and executes only that.


// Build-time: compute a stable hash for each mutation document
import { createHash } from "node:crypto";

function hashOperation(source) {
  // Normalize whitespace before hashing so formatting changes
  // don't produce a different hash for the same operation
  const normalized = source.replace(/\s+/g, " ").trim();
  return createHash("sha256").update(normalized).digest("hex");
}

const updateCustomerAddress = `
  mutation UpdateCustomerAddress($id: ID!, $input: AddressInput!) {
    updateCustomerAddress(id: $id, input: $input) {
      id
      street
      city
    }
  }
`;

console.log(hashOperation(updateCustomerAddress));
// e.g. "a3f1c9..." — this hash becomes the operation's public identifier

Because the hash is computed over the operation text, every change to the mutation, a new field, a different argument, automatically produces a new hash. This forces an explicit build and deployment step for every frontend schema change, which additionally protects GraphQL persisted mutations against accidental client-server schema drift.

4. Implementing a server-side allowlist

The server-side implementation consists of a simple but strict lookup: when a request arrives with an operation hash not present in the allowlist, the request is rejected immediately with an error, without the GraphQL executor ever being invoked. The example below shows an Apollo Server plugin doing exactly that.


// Apollo Server plugin enforcing a strict mutation allowlist
import { readFileSync } from "node:fs";

const manifest = JSON.parse(readFileSync("./persisted-operations.json", "utf-8"));

const persistedMutationsPlugin = {
  async requestDidStart() {
    return {
      async didResolveOperation({ request, document }) {
        const isMutation = document.definitions.some(
          (def) => def.kind === "OperationDefinition" && def.operation === "mutation"
        );
        if (!isMutation) return; // queries handled by separate, less strict policy

        const hash = request.extensions?.persistedQuery?.sha256Hash;
        if (!hash || !manifest[hash]) {
          throw new Error("Mutation rejected: not present in persisted operations allowlist");
        }
      },
    };
  },
};

It is crucial that this check happens before actual execution, not just inside the resolver. If the check ran only in the resolver, the server would have already spent time and resources on parsing and validating a potentially malicious operation, a denial-of-service vector that GraphQL persisted mutations avoid specifically through early rejection.

5. Generating the operations manifest at build time

The manifest, a simple JSON file mapping hashes to operation texts, is generated automatically in the frontend build process, usually through a Babel plugin or an ESBuild loader that collects every gql-tagged template in the source code. No developer maintains this manifest by hand, every new mutation in the code appears automatically on the next build.


{
  "a3f1c9d2e8b47600...": {
    "operationName": "UpdateCustomerAddress",
    "type": "mutation",
    "source": "mutation UpdateCustomerAddress($id: ID!, $input: AddressInput!) { ... }"
  },
  "7be2f0a91c3d5642...": {
    "operationName": "AddToCart",
    "type": "mutation",
    "source": "mutation AddToCart($sku: String!, $qty: Int!) { ... }"
  }
}

This generated manifest gets published as its own artifact in the CI process and must be available on the GraphQL server before the frontend deployment goes live, otherwise a freshly deployed frontend would send mutations the server doesn't know about yet. The deployment order, server manifest first, then frontend, is not optional with GraphQL persisted mutations, it is a hard requirement.

6. Protection against mutation injection and batching attacks

Without an allowlist, an attacker with access to the endpoint and a valid token can construct any mutation technically permitted by the schema, including ones never intended for public access, for instance administrative mutations that were accidentally shipped without an additional role check. GraphQL persisted mutations prevent this structurally: even a mutation present in the schema but never used in the frontend cannot be executed, because its hash never ends up in the manifest.

A second attack vector is query batching, where a single HTTP request contains an array of many operations to evade rate limits that count per request instead of per operation. Persisted mutations mitigate this risk further, since every operation in the batch is checked individually against the allowlist and the number of possible mutation types stays clearly bounded by the fixed manifest size, an attacker cannot invent arbitrary new mutation variants to dodge detection patterns.

7. Combining rate limiting and authorization

GraphQL persisted mutations are not a replacement for authorization, but an additional protective layer in front of it. A user with a valid token can still only execute the mutations permitted for them, on their own data, that still has to be checked in the resolver. Combined with rate limiting per operation hash, additional abuse patterns become detectable, for instance an unusually high number of AddToCart calls from a single IP address in a short time window.


// Combine persisted mutation allowlist with per-operation rate limiting
import { RateLimiterMemory } from "rate-limiter-flexible";

const limiters = new Map();

function getLimiterFor(operationHash) {
  if (!limiters.has(operationHash)) {
    limiters.set(operationHash, new RateLimiterMemory({ points: 20, duration: 60 }));
  }
  return limiters.get(operationHash);
}

async function checkMutationRateLimit(operationHash, clientId) {
  const limiter = getLimiterFor(operationHash);
  await limiter.consume(clientId); // throws when the limit is exceeded
}

This combination of allowlist, authorization, and per-hash rate limiting forms the complete defense line: GraphQL persisted mutations restrict which operations are possible at all, authorization restricts who may run them with which data, and rate limiting restricts how often.

8. CI/CD workflow: deploying the manifest

The full deployment workflow for GraphQL persisted mutations consists of four automated steps that integrate cleanly into an existing CI pipeline.


# .github/workflows/deploy-persisted-operations.yml
name: Deploy Persisted Operations Manifest
on:
  push:
    branches: [main]

jobs:
  publish-manifest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Extract operations and build manifest
        run: npm run generate:persisted-manifest
      - name: Upload manifest to GraphQL server storage
        run: |
          curl -X PUT "$GRAPHQL_SERVER_URL/admin/persisted-operations" \
            -H "Authorization: Bearer $DEPLOY_TOKEN" \
            -H "Content-Type: application/json" \
            --data-binary @dist/persisted-operations.json
      # Server must confirm the manifest is active before frontend deploy proceeds
      - name: Verify manifest is active
        run: npm run verify:manifest-active

The final verification step is not an optional convenience, it prevents a race condition: without this check, the frontend deployment could start before the server has actually loaded the new manifest, causing failing mutations for every user during the rollout.

9. Protection mechanisms compared

The table below places persisted mutations alongside related protection mechanisms.

Mechanism Protects against Blocks unknown mutations?
No protection Nothing No
Persisted queries (with fallback) Bandwidth, not security No
Automatic Persisted Queries (APQ) Payload size, caching No
Query complexity limits Resource exhaustion from queries No
GraphQL persisted mutations Mutation injection, unvetted write access Yes, strictly, no fallback

Mironsoft

GraphQL security, API hardening, and write access protection

Actually securing your write GraphQL operations?

We implement persisted mutations for your GraphQL endpoint, including the build pipeline, server allowlist, and combined rate limiting, so only your own frontends can write.

Security audit

Review existing GraphQL mutations for unprotected write access

Persisted mutations setup

Set up manifest generation, server allowlist, and CI deployment

Rate limiting

Build abuse detection per operation and per client

10. Summary

GraphQL persisted mutations shift the security decision from "which values are valid" to "which operation is even allowed to run." Through a deterministic hash per mutation and a strict, fallback-free server-side allowlist, every operation that doesn't come exactly from your own controlled frontend build gets rejected. That closes off mutation injection, accidentally unprotected administrative mutations, and a large share of automated abuse attempts, before the GraphQL executor even becomes active.

What matters for production use is a clean separation between persisted queries with fallback for performance and persisted mutations with no fallback for security, plus a reliable CI/CD workflow that always activates the server manifest before the corresponding frontend deployment. Combined with classic authorization and rate limiting per operation hash, this forms a layered defense far more robust than input validation alone.

GraphQL Persisted Mutations — The key facts at a glance

No fallback

Unlike persisted queries, an unknown hash for mutations must never trigger free-form execution.

Build-time manifest

Hashes get extracted automatically from the frontend build, no manually maintained list.

Deployment order

Server manifest must be live before the frontend, otherwise mutations fail during rollout.

Additional layer

Does not replace authorization, adds a structural boundary against unknown operations.

11. FAQ: GraphQL Persisted Mutations

1What are persisted mutations?
The client sends only a hash instead of the full mutation, the server executes only pre-registered, vetted operations.
2Difference from persisted queries?
Queries often allow fallback for performance, mutations must have no fallback or the protection is void.
3Does this replace authorization?
No, it only restricts which operations exist, authorization still checks permission on a per-case basis.
4Changing a mutation in the frontend?
Produces a new hash, the updated manifest must be active before the frontend deploys.
5Works with Federation?
Yes, checking at the gateway protects the entire federated system centrally before forwarding to subgraphs.
6How much overhead?
Minimal, a hash lookup takes microseconds and runs before the more expensive GraphQL execution.
7Can it roll out gradually?
Yes, with a transition mode that logs unknown hashes first instead of rejecting them immediately.
8Protection against DoS?
Partial, combine with rate limiting and query complexity limits for full protection.
9Worth it for internal APIs?
Smaller but real benefit, protects against compromised credentials and accidentally exposed mutations.
10Dynamically generated mutations?
Fit poorly with a static manifest, better to use a limited set of parameterized mutations instead.