Security hardening — the graph attack surface
GraphQL gives clients a query language. Untamed, that's a self-service attack console. Here's the hardening baseline we apply on every engagement.
The baseline (non-negotiable)
1. Depth and complexity limits
A public graph without query limits is a denial-of-service endpoint waiting for a script kiddie:
# 6 keystrokes of nesting = exponential work
{ orders { customer { orders { customer { orders { ... }}}}}}
- Depth limit: 8–12 covers every legitimate UI we've ever seen.
- Complexity/cost limit: assign field costs (lists cost
first × child), reject over budget before execution. - Enforce at the router and subgraphs — defense in depth, because internal callers misbehave too.
2. Persisted / trusted operations
The single highest-leverage control. Clients register operations at build time; production accepts only registered hashes:
- Arbitrary-query attacks: gone.
- Introspection abuse: moot.
- Deprecation tracking: exact (see versioning).
If you have first-party clients only, there is no good reason not to run persisted-only. Do it before you need it.
3. Introspection off in production
Not a security control by itself (persisted queries are), but leaving it on hands attackers your full schema, including that internalAdminNotes field someone shipped in 2024. Disable it publicly; keep it on for internal tooling behind auth.
4. Error masking
Raw resolver errors leak stack traces, SQL fragments and internal hostnames into the errors array. Mask everything except typed user errors at the boundary; log the real error server-side with a correlation ID that is returned to the client.
Authorization: at the field, not the route
The classic REST mistake transplanted to GraphQL: auth checks at the "endpoint" level. There are no endpoints — there is one endpoint and infinite queries. Authorization must live in the domain layer that resolvers call, so every path to a field hits the same check:
- Never trust "this field is only reachable from an admin query." Aliases and fragments make every field reachable.
- Watch nullability: an unauthorized non-null field nulls out its parent — model restricted fields as nullable and return
null, indistinguishable from absence. - In federation,
@requires/entity resolution means subgraphs receive traffic the user never wrote. Propagate the caller's identity (not the router's service account) in every subgraph hop, and authorize there too.
Rate limiting that understands the graph
Request-per-minute limits are meaningless when one request can cost 10,000× another. Rate-limit on consumed complexity points, per client, per window. Your complexity calculator (point 1) gives you the currency for free.
Audit checklist
- Depth + complexity limits at router and subgraphs
- Persisted operations only (or a dated plan to get there)
- Introspection disabled on the public endpoint
- Errors masked; correlation IDs returned
- Field-level authz in the domain layer, identity propagated to subgraphs
- Complexity-based rate limiting
- CSRF protection if cookies are involved;
application/jsoncontent-type enforced