All resources
// Resources

GraphQL Security Review Checklist

Published June 14, 2026

Resolver policy and query demand controls

Trace a GraphQL field from schema to resolver and data source. viewer may obtain subject from context, while node(id:), a connection edge, subscription event, and bulk mutation each need their own object decision. Create two tenant fixtures and request global IDs through every field that can reach protected data. A resolver returning null must also avoid leaking count, error detail, or loader cache populated with foreign object.

Calculate demand before execution. Pagination limits list output; depth limit handles nested traversal; alias and batch limits stop wide shallow documents; weighted complexity distinguishes a cached name from a report aggregation. Test a five-level relationship query and a one-level query with many aliases. For first-party clients, trusted document hashes can reject unknown operations, but they do not replace resolver authorization or public-client safeguards.

A failing document should return masked error without downstream fan-out. Evidence includes query hash or redacted document, calculated cost, rejected reason, resolver decision, database query count, and response. Fix by adding field policy, pagination, complexity weights, or operation allowlist. Closure reruns same document and an allowed near-limit document, confirming denial occurs before expensive resolver work.

Subscriptions need separate scrutiny because authorization at connection establishment may become stale. Define whether every event rechecks recipient access and how revoked membership removes delivery. DataLoader batching must not merge tenant contexts into one cache key. For mutation input, schema types help parsing but do not authorize field changes; allowlist writable properties in business logic. Capture a denied subscription or node lookup alongside database trace to prove no cross-tenant resolver result was cached.

Authorize at resolver boundaries

GraphQL authorization starts with execution context but ends inside resolvers. Trace viewer, node(id:), list connections, nested fields, bulk mutations, and subscriptions to their data source and policy decision. Create two tenant fixtures with matching roles. Request a foreign global ID through every resolver that can reach it. A safe viewer does not make node safe, and a parent authorization result does not automatically authorize attachment or child connection.

Expected denied output is intentionally small: a masked error or null according to schema policy, no foreign field values, no diagnostic stack, and no downstream data fetch that seeds shared cache. Inspect DataLoader keys; tenant or subject-relevant dimensions must prevent a permitted resolver from filling cache for another caller. For mutations, allowlist writable properties in business logic. Input types validate shape, not authority to change role, tenantId, or approval status.

Demand-control cases

Depth alone is insufficient. Send a cyclic five-level relationship query, then a shallow query with hundreds of aliases, then a batch containing many individually valid operations. Pagination needs maximum first and last; complexity weights should charge costly aggregation more than a scalar name. A first-party trusted-document hash may reject unreviewed operations, but public clients still need limits. Before execution, the over-budget document should return a stable error and perform no expensive resolver or provider work.

Test variables as well as literal query text: oversized page size, foreign ID, array of IDs, and a persisted-operation identifier with altered variables. Subscription authorization needs recheck after membership revocation, not only when WebSocket opens. Capture query hash or redacted document, calculated depth and cost, resolver decision, database-query count, cache result, and response correlation ID.

Closure evidence

Repair with resolver policy, per-field property rules, pagination, depth and alias ceilings, batch limit, weighted cost, or operation allowlist. Rerun denied document and an allowed near-limit document. A pass proves former stopped before protected work while latter completed with bounded count. If production introspection is restricted, retain a reviewed schema-release process; hiding schema never substitutes for authorization. Any exception for an integration names operation hash, caller, max cost, expiry, and audit review.

Transport and error checks

Apply ordinary API controls to GraphQL transport: HTTPS, request body maximum, authentication, origin policy where browser credentials apply, timeout, and no sensitive shared caching. A complexity calculation cannot rescue an endpoint that accepts an oversized body or unauthenticated mutation. Test malformed document and invalid variable type; public error should reveal enough for client correction without publishing stack traces, datasource names, or field suggestions beyond chosen policy.

For subscriptions, record connection principal, authorization decision, event filter, and revocation behavior. Remove tenant membership during an active subscription and confirm later event is suppressed. For federated schemas, trace entity resolution across subgraphs; one subgraph must not return a foreign entity because gateway checked only entry query. Closure evidence pairs resolver test with transport test so a correct field policy is not masked by an unsafe delivery channel.

GraphQL resolver review artifact

Resolver controlGraphQL ownerReview statusQuery evidenceExpected resolver resultObserved resolver resultPolicy exceptionResolver closure
GQL-AUTH-08 foreign node(id:)account API ownerFailedredacted query hash, resolver trace, DB query count, response IDmasked denial before foreign row fetch; tenant-scoped loader keyresolver denied output after foreign row fetch seeded shared loadernone; cross-tenant cache behavior cannot shipscope loader key by tenant, rerun both tenant fixtures, confirm zero foreign fetch

Sources

Have a system that needs testing?