GraphQL gives clients the flexibility to request exactly the data they need. A client can send a query describing the fields it wants, and the server resolves those fields into a response. This flexibility is useful, but it also creates challenges when an application needs tighter control over which operations clients can execute.
In a production application, unrestricted GraphQL queries can increase server workload, expose expensive execution paths, and make it harder to predict API usage. A client might submit a deeply nested query, request a large number of records, or repeatedly execute operations that consume significant database resources.
Persisted queries provide one way to address these challenges. Instead of sending the complete GraphQL query with every request, a client can identify a previously registered operation using a hash or another stable identifier. The server then looks up the corresponding query and executes it.
When persisted queries are configured as an allowlist, the server accepts only operations that have been registered and approved. This gives API teams more control over the operations available to clients.
This article explains how GraphQL persisted queries work, how they differ from ordinary query caching, how to implement an allowlist, and what developers should consider before using them in production.
What Are GraphQL Persisted Queries?
A persisted query is a GraphQL operation that has been registered with a server or associated with a known identifier.
Consider a client that needs to retrieve a customer's basic profile information.
A regular GraphQL request might contain the complete query:
query GetCustomerProfile($customerId: ID!) {
customer(id: $customerId) {
id
name
email
}
}
The client sends this operation to the GraphQL endpoint along with the variable values.
With a persisted-query approach, the client can send an identifier associated with the registered operation instead of transmitting the entire query document.
A simplified request might look like this:
{
"operationId": "customer-profile-v1",
"variables": {
"customerId": "CUST-1042"
}
}
The server uses operationId to retrieve the registered GraphQL document and then executes it with the supplied variables.
The identifier in this example is illustrative. Real implementations may use a cryptographic hash of the query document, a generated operation identifier, or another controlled mapping.
Persisted queries can reduce request payload sizes and provide a foundation for restricting which operations clients can execute.
Persisted Queries vs. Automatic Persisted Queries
The terms persisted queries and automatic persisted queries are related, but they do not always describe the same security model.
Persisted queries
In a persisted-query system, operations are registered ahead of time or maintained in a controlled registry. The client references an operation that the server already knows.
If the registry is configured as an allowlist, unknown operations are rejected.
Automatic persisted queries
Automatic Persisted Queries, commonly abbreviated as APQ, typically use a hash of the GraphQL document.
A client initially sends the hash. If the server does not recognize it, the client may retry with the complete query document. The server can then calculate the hash, store the query, and use the hash for subsequent requests.
This approach improves request efficiency after the query has been registered, but the initial fallback can permit clients to introduce arbitrary operations.
Therefore, APQ alone does not necessarily enforce an operation allowlist.
Feature | Persisted-query allowlist | APQ with query registration |
|---|
Operation registration | Usually controlled ahead of execution | May occur automatically on first use |
Unknown operation | Rejected | May trigger a full-query retry |
Payload size | Can be smaller because clients send identifiers | Can be smaller after registration |
Security control | Can restrict execution to approved documents | Depends on registration and fallback policies |
Client flexibility | Limited to registered operations | Can support dynamically introduced queries |
Typical use case | Production APIs with controlled clients | Reducing repeated query payloads |
The important distinction is that a query hash identifies a document; it does not automatically make that document trusted.
Why Use an Operation Allowlist?
Persisted-query allowlists can improve control over GraphQL API behavior.
Reduce exposure to expensive queries
GraphQL allows clients to request nested fields and relationships. Without suitable controls, a query can trigger expensive resolver chains or retrieve large amounts of data.
An allowlist restricts clients to known operation documents. This reduces the risk of clients inventing unexpected query structures.
However, an approved query can still be expensive when it accepts large pagination values, invokes costly resolvers, or processes many records. Allowlisting should be combined with resource limits and appropriate authorization.
Control which operations clients can execute
A team may want mobile applications and browser clients to use only operations reviewed during development.
For example, a public-facing application might be permitted to retrieve product summaries but not execute internal reporting queries.
An allowlist makes this distinction easier to enforce because the server knows which operations are available to each client or application version.
Improve observability
Registered operations have stable identifiers and names. This makes it easier to associate API traffic with known operations, investigate performance issues, and monitor usage patterns.
Metrics can track execution counts, latency, error rates, and resource consumption by operation.
Reduce repeated query payloads
Sending an identifier instead of the complete query document can reduce request size, particularly when clients repeatedly execute the same operation.
The actual benefit depends on the size of the original query, the transport format, and the overhead introduced by the identifier and variables.
How to Implement a Persisted-Query Allowlist
The exact implementation depends on the GraphQL server framework. The following example demonstrates the core design using Node.js and Express with a GraphQL execution library.
The goal is to accept only registered operations and reject requests containing arbitrary GraphQL documents.
Step 1: Define the approved operations
Start with a registry that maps operation identifiers to reviewed GraphQL documents.
const persistedOperations = new Map([
[
"customer-profile-v1",
`
query GetCustomerProfile($customerId: ID!) {
customer(id: $customerId) {
id
name
email
}
}
`
],
[
"product-summary-v1",
`
query GetProductSummary($productId: ID!) {
product(id: $productId) {
id
name
price
}
}
`
]
]);
This registry contains two approved operations.
The first retrieves a customer's profile, while the second retrieves a product summary. The client can choose between these operations, but it cannot introduce a third operation unless the server's registry is updated.
For a small demonstration, an in-memory map is sufficient. Production systems may load the registry from a generated build artifact, a controlled configuration store, or a persisted-query management service.
The registry should be updated through a review and release process rather than through unrestricted client requests.
Step 2: Validate the operation identifier
The server should require an operation identifier and look it up in the registry before execution.
function getPersistedOperation(operationId) {
if (typeof operationId !== "string" || operationId.length === 0) {
throw new Error("A valid operation ID is required.");
}
const query = persistedOperations.get(operationId);
if (!query) {
throw new Error("Unknown persisted operation.");
}
return query;
}
This function rejects missing identifiers and identifiers that do not exist in the registry.
Do not automatically accept an unknown identifier by registering the query supplied by the client. Doing so would undermine the allowlist.
The identifier should be treated as a lookup key, not as proof that a user is authorized to access the data returned by the operation.
Step 3: Execute the registered document
The following example illustrates how the registered document can be passed to GraphQL execution.
import express from "express";
import {
graphql,
buildSchema
} from "graphql";
const app = express();
app.use(express.json());
const schema = buildSchema(`
type Customer {
id: ID!
name: String!
email: String!
}
type Product {
id: ID!
name: String!
price: Float!
}
type Query {
customer(id: ID!): Customer
product(id: ID!): Product
}
`);
const rootValue = {
customer: ({ id }) => ({
id,
name: "Aarav Sharma",
email: "[email protected]"
}),
product: ({ id }) => ({
id,
name: "Developer Keyboard",
price: 3499
})
};
app.post("/graphql", async (req, res) => {
try {
const { operationId, variables } = req.body;
const source = getPersistedOperation(operationId);
const result = await graphql({
schema,
source,
rootValue,
variableValues: variables
});
res.status(200).json(result);
} catch (error) {
res.status(400).json({
errors: [
{
message: error.message
}
]
});
}
});
app.listen(3000, () => {
console.log("GraphQL server listening on port 3000");
});
This is a simplified example of the allowlist flow, not a complete production server.
In particular, the sample resolvers return demonstration data and do not implement authentication, authorization, database access, or production-grade error handling. A real application must connect the schema to its actual data sources and apply the relevant security controls.
The important part is that the server retrieves source from the registry. It does not accept a client-provided GraphQL document as an alternative.
The example also assumes that the registry and schema have been initialized before requests arrive. The operationId should be validated before execution, and variables must be checked against the operation's expected types by GraphQL.
Step 4: Send a persisted-query request
A client can now call the endpoint using an approved operation identifier.
const response = await fetch("/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
operationId: "customer-profile-v1",
variables: {
customerId: "CUST-1042"
}
})
});
const result = await response.json();
console.log(result);
The client does not need to include the complete GraphQL document. The server resolves the identifier to the approved query and executes it with the supplied variables.
If the client submits an identifier such as customer-delete-v1 and that operation is not registered, the request is rejected before GraphQL execution.
A production API should return a consistent error format for unknown operations. Avoid exposing internal registry details or stack traces to external clients.
Using Query Hashes Instead of Named Identifiers
Named identifiers are easy to understand, but a larger application may prefer a hash-based registry.
A common approach is to calculate a SHA-256 hash of the exact GraphQL document during the build process.
Conceptually, the registry maps:
SHA-256(query document) -> approved GraphQL document
The client sends the hash, and the server retrieves the corresponding document.
This approach provides a stable content-derived identifier. If the query document changes, its hash changes as well.
However, hashing does not itself enforce trust. The server must compare the supplied hash against a trusted registry. It should not accept an arbitrary query, calculate its hash, and register it automatically when strict allowlisting is required.
Both the client build process and server registry must agree on the query representation used to generate the hash. Differences in whitespace, formatting, or normalization can affect the hash when the exact document bytes are used.
For this reason, teams should generate the registry and client operation identifiers through a consistent build pipeline.
Security Considerations for Production
Persisted queries are one part of GraphQL security, not a complete replacement for authorization and resource controls.
Authenticate requests
The server should authenticate the caller using the application's normal authentication mechanism.
An approved operation identifier must not grant access by itself. A user who can execute GetCustomerProfile should still be permitted to retrieve only the customer records they are authorized to access.
For example, the resolver should verify that the requested customer belongs to the authenticated user or that the user has an appropriate administrative permission.
Authorize data access inside resolvers
A persisted query can limit the available query structure, but it cannot guarantee that the caller is entitled to every returned field or record.
Authorization checks should remain close to the data-access layer or resolver logic, where the application can evaluate the current user, requested resource, and applicable permissions.
Do not rely exclusively on hiding operation identifiers in client code.
Validate variables and enforce limits
Even an approved operation can be abused through its variables.
Consider a registered operation that accepts a first argument for pagination. If the server allows a client to request an excessively large number of records, the operation can still consume substantial memory and database capacity.
Apply maximum page sizes, input validation, execution timeouts, and database query limits where appropriate.
For APIs that support complex nested queries, depth limits and query-complexity analysis may provide additional protection.
Control registry updates
Treat the operation registry as a versioned application artifact.
A suitable workflow includes generating operations during the client build, reviewing changes, validating documents against the server schema, and deploying compatible registry versions.
Avoid allowing arbitrary users to register new operations in a production allowlist.
Avoid leaking sensitive information
Unknown operation errors should not reveal internal implementation details. Server logs can include the operation identifier, request correlation ID, execution duration, and other diagnostic information that is safe for the environment.
Be careful when logging variables because they may contain personal information, access tokens, or other sensitive values.
Common Mistakes and Troubleshooting
Unknown operations are accepted
Check whether the server has a fallback that accepts a complete query document and registers it automatically. That behavior may be appropriate for a controlled APQ workflow, but it conflicts with a strict allowlist.
Valid operations fail after deployment
The client and server may be using different registry versions. Ensure the server has the required operation before deploying a client that depends on it.
A safe rollout generally deploys new operations before clients begin using them. Remove old operations only after supported clients no longer depend on them.
Query hashes do not match
Verify that the hash is calculated from the same document representation on both sides. Check the exact bytes, encoding, and any document transformations performed during the build.
Generating the client manifest and server registry from the same source of truth helps avoid these mismatches.
Approved queries still cause high resource usage
An allowlist does not prevent every form of expensive execution. Inspect resolver performance, database query plans, pagination limits, caching behavior, and the number of times the operation is executed.
Use monitoring to identify expensive operations and optimize the underlying data access rather than assuming that registration makes an operation inexpensive.
Advantages and Disadvantages
Advantages
Restricts clients to known GraphQL operations when used as a strict allowlist.
Reduces repeated transmission of complete query documents.
Makes operation-level monitoring and performance analysis easier.
Supports controlled API evolution through versioned operation registries.
Reduces the ability of clients to introduce arbitrary query structures.
Disadvantages
Requires coordination between client builds and server registry deployments.
Adds a registry-generation and maintenance process.
Can complicate development workflows that rely on arbitrary queries.
Does not replace authentication, authorization, pagination limits, or rate limiting.
Can cause compatibility problems when operations are removed before older clients stop using them.
Conclusion
GraphQL persisted queries provide a practical way to control which operations an application can execute. When the server accepts only operations from a trusted registry, persisted queries become an allowlisting mechanism rather than merely a request-size optimization.
The essential implementation principle is simple: resolve the client-supplied identifier against the approved registry, reject unknown operations, and execute only the registered document. Do not silently register arbitrary client queries when strict operation control is required.
For production systems, combine persisted-query allowlisting with authentication, resolver-level authorization, variable validation, pagination limits, rate limiting, and monitoring. Manage registry updates alongside client releases so that new operations are available before clients depend on them.
This approach gives teams better control over GraphQL execution while preserving the flexibility of GraphQL for the operations that have been reviewed and approved.
Join the conversation! Your thoughts help the community grow.