Securing Public GraphQL APIs with Persisted Query Allowlists
Stop arbitrary GraphQL execution on public endpoints by implementing a SHA-256 persisted query allowlist, separating public identity-based access from internal ad-hoc querying.
30 Jun 2026, 16:21 UTC

Problem: Arbitrary Query Execution on Public Endpoints
Public GraphQL APIs are vulnerable to abuse because they typically allow clients to send arbitrary query strings. This opens the door to expensive resolvers (DoS), schema leakage via introspection, and high CPU overhead from parsing complex, malicious queries at the edge. The useful takeaway is to shift from trusting query text to trusting query identity: enforce a SHA-256 hash allowlist before the request ever reaches the GraphQL parser.
Requirements for a Restricted Public Surface
To secure a public endpoint without losing the benefits of GraphQL, the architecture must meet these criteria:
- Zero Arbitrary Execution: No query text provided by the client should be executed.
- Edge Efficiency: Requests should be rejected based on a hash lookup before expensive parsing occurs.
- Schema Privacy: Introspection must be disabled for public callers.
- Internal Flexibility: Developers and operators still need ad-hoc query capabilities for debugging and tooling.
Persisted queries solve this by replacing the full query string with a unique identifier (usually a SHA-256 hash). The server maintains a mapping of these hashes to the actual query strings.
The Smallest Suitable Design
The most efficient implementation splits the API into two distinct trust zones: a public persisted-query endpoint and a protected internal endpoint.
Public Endpoint Configuration
The public endpoint is configured as POST-only. It explicitly rejects any request containing a query field in the body to prevent bypass attempts. It only accepts requests containing a hash in the extensions object.
{
"operationName": "GetProductDetails",
"variables": { "id": "prod_99" },
"extensions": {
"persistedQuery": {
"sha256": "a1b2c3d4e5f6..."
}
}
}The server performs a lookup in an in-memory allowlist. If the hash is missing, it returns a 400 Bad Request immediately. If present, the server retrieves the associated query string and passes it to the GraphQL engine.
Client Registration Flow
To avoid manual configuration, queries are registered during the client's build process:
- The client-side build tool extracts all GraphQL operations from the source code.
- The tool generates SHA-256 hashes for each query string.
- These hashes and strings are pushed via CI to a developer portal or a configuration repository.
- The API server pulls this allowlist during deployment or via a configuration update.
Internal Endpoint Configuration
A separate endpoint (different path or host) is created for internal use. This endpoint requires mTLS (mutual TLS) or a high-privilege service token. It allows full query text and introspection, providing the flexibility needed for operations without exposing it to the public.
Trust and Data Boundaries
The trust boundary moves from input validation (checking if a query is "too deep") to identity validation (checking if a query is "known").
The public API treats the allowlist as the sole source of truth for valid query shapes. Because the server controls the query string associated with the hash, the client cannot modify the query structure to access unauthorized fields. Data boundaries are strictly enforced by the mapping; if a schema change breaks a persisted query, a new hash must be registered and deployed.
Operational Checks and Verification
Monitoring should focus on the gap between client expectations and server state. Key metrics include:
- Hash Miss Rate: A spike in 400 errors for missing hashes usually indicates a client deployment that occurred before the API allowlist was updated.
- Lookup Latency: Ensure the allowlist lookup (typically an O(1) map) does not introduce measurable overhead.
- Rejection Volume: Track the number of requests containing a
queryfield, which may indicate probing attempts.
Verification Steps:
- Run a POST request without
extensions.persistedQuery.sha256; verify a 400 response. - Run a POST request with a valid hash but including a
querybody; verify the request is rejected. - Attempt an introspection query on the public endpoint; verify it is blocked regardless of the hash.
- Verify the internal endpoint accepts a standard GraphQL query when presented with valid mTLS certificates.
Failure Modes
Client-Server Desynchronization: If a client ships a new version with a new query hash before the server has updated its allowlist, the feature will break for all users. This is mitigated by treating the allowlist as a deployment dependency—the allowlist must be updated before the client is released.
Hash Collisions: While SHA-256 collisions are computationally improbable, the system must handle them by ensuring the registration process rejects duplicate hashes for different query strings.
When to Change the Design
This architecture is ideal for fixed-client applications (web/mobile apps). However, the design must change if:
- Third-party Developers: If you provide a public API for external developers to build their own apps, a strict allowlist is impossible. You must transition to Query Cost Analysis (assigning weights to fields) and Depth Limiting to prevent abuse.
- Dynamic Querying: If the UI requires highly dynamic filters that cannot be pre-defined, you must implement a limited set of "flexible" persisted queries that use carefully constrained variables.
Limitation: Persisted queries prevent unauthorized shapes, but they do not prevent expensive authorized shapes. A single allowed query that fetches 1,000 records can still stress the database. Combine this approach with pagination limits and timeouts.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.