Authorization And Security
Authentication Sits Outside GraphQL
Establishing who is calling is ordinary web auth and happens before execution: a session cookie, a bearer token, an API key — validated in middleware exactly as for any other endpoint. The result (the current user, or null) goes into the context object handed to every resolver. GraphQL neither knows nor cares how you authenticated; it only needs the answer available during execution.
Authorization Belongs In The Business Layer
The central rule: authorize in your business logic, not in the graph. It is tempting to sprinkle checks across resolvers — "in Query.album, verify the user may view albums" — but resolver-level checks fail in a graph, because there are many paths to the same object. Album is reachable via Query.album, via Artist.albums, via Review.album, via search. A check guarding one doorway protects nothing.
Instead, put the rule where the data is produced, once:
final class AlbumService
{
public function find(User $viewer, string $id): ?Album
{
$album = $this->repo->find($id);
if ($album === null) {
return null;
}
if ($album->isUnreleased() && !$viewer->worksWith($album->artistId)) {
return null;
}
return $album;
}
}
Every resolver that produces an album calls this method with the viewer from context. Now the rule holds no matter which edge the query walked in through — and the same service enforces it for your REST endpoints and CLI tools too. Resolvers stay thin: translate GraphQL requests into business-layer calls; let that layer say no.
Two supporting practices:
- Declarative directives are sugar, not the source of truth. Some servers let you annotate the SDL (
@auth(requires: ADMIN)-style directives) to attach checks to types and fields. They are fine as a visible summary, but they compile down to per-field checks — keep the actual rules in the business layer so REST endpoints, CLI tools, and directive-less paths enforce the same policy. - Field-level rules ride on top. Some fields are sensitive on otherwise visible objects (
User.email). Enforce these in the type's field resolution — again by delegating to a policy, not by hand-rolled checks per query path. - Deny by returning what non-existence returns. For "you may not see this object," prefer the same
null/not-found the client would get if the object did not exist; a distinct "forbidden" error confirms the object exists. For "you may not do this action" (mutations), an explicit permission error is fine and kinder.
Remember also the lesson from introspection: hiding schema parts is not authorization. And for subscriptions, re-evaluate permissions at delivery time — a stream opened while authorized can outlive the authorization.
Securing The Endpoint Against Expensive Queries
The distinctive GraphQL threat is not injection — parameterized variables and typed arguments handle classic injection well — it is resource exhaustion: clients can compose pathological queries.
{
artist(slug: "nova-tide") {
albums { tracks { album { tracks { album { tracks { title } } } } } }
}
}
Cyclic relationships let a tiny document explode into millions of resolver calls. Standard defenses, best applied in layers:
- Depth limiting. Reject documents nested beyond a bound (say, 10–15 levels). Cheap, catches the silly cases;
graphql-phpships aQueryDepthvalidation rule. - Complexity/cost analysis. Assign each field a cost, multiply through list arguments (
first: 100makes children 100× costlier), sum the document, and reject over budget before executing. This is the serious control; aQueryComplexityrule exists ingraphql-php. - Breadth and batch limiting. Depth is not the only axis: a shallow document can repeat one expensive field hundreds of times under different aliases, and servers that accept arrays of operations per request multiply everything again. Cap aliases per selection set (or fold them into the cost model) and cap or disable operation batching.
- Pagination caps. Every list field takes a bounded
firstargument with an enforced maximum — no field ever returns an unbounded collection. (The pagination lesson makes this structural.) - Argument validation. The type system checks shape, not meaning: a
Stringargument happily carries ten megabytes or hostile markup. Length-limit and validate argument values in the business layer, exactly as you would any user input. - Timeouts and result limits as the backstop for whatever slips through the estimates.
- Rate limiting — but per cost consumed, not per request, since one GraphQL request can equal a thousand REST requests. Budget points per client per minute.
Locking Down Known Clients: Persisted Queries
If all your clients are ones you build (mobile apps, your own SPA), you can go further: at build time, extract every query the client uses, store them server-side keyed by hash, and in production accept only those hashes — the endpoint refuses arbitrary documents entirely. This is the allowlisted persisted queries pattern. It converts an open query language into a fixed set of vetted operations, eliminating the arbitrary-query attack surface, and shrinks requests to a hash as a bonus. (The related "automatic persisted queries" variant is a bandwidth optimization only, not a security control — it registers unknown hashes on demand.) Public APIs cannot use allowlisting, which is why the cost-analysis layer matters there.
The Rest Of The Checklist
The boring things still apply and still cause the actual incidents: TLS everywhere; error masking on (no stack traces or SQL in errors); introspection decided deliberately per environment; CSRF protection when cookies are in play; dependency updates for your GraphQL library; and logging of operation name, viewer, cost, and duration for every request so abuse is visible.
What To Check
Before moving on, make sure you can:
- explain why authorization checks inside individual resolvers are unreliable in a graph
- show where authentication happens and how the viewer reaches resolvers
- decide when denied access should look like non-existence versus an explicit error
- describe depth limiting, cost analysis, pagination caps, and cost-based rate limiting, and order them by rigor
- explain what allowlisted persisted queries eliminate and why the "automatic" variant does not
- list the transport-layer basics that still apply to a GraphQL endpoint
What You Should Be Able To Do
After this lesson, you should be able to design the protection story for a GraphQL API: viewer in context, rules centralized in the business layer, layered query-cost defenses sized to whether the audience is public or known clients — and audit an existing endpoint for the classic gaps (unbounded lists, resolver-sprinkled checks, debug errors in production).