GraphQL Security Review Checklist
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 control | GraphQL owner | Review status | Query evidence | Expected resolver result | Observed resolver result | Policy exception | Resolver closure |
|---|---|---|---|---|---|---|---|
GQL-AUTH-08 foreign node(id:) | account API owner | Failed | redacted query hash, resolver trace, DB query count, response ID | masked denial before foreign row fetch; tenant-scoped loader key | resolver denied output after foreign row fetch seeded shared loader | none; cross-tenant cache behavior cannot ship | scope loader key by tenant, rerun both tenant fixtures, confirm zero foreign fetch |