In a REST API, an operation has one endpoint, so the authorisation check has one place to live. Get it right there and every caller goes through it.
GraphQL removes that property. A field can be reached by many paths through the graph, and a check on the top-level query does not protect the same data reached as a nested field of something else. Authorisation stops being a property of an endpoint and becomes a property of every resolver.
This post is what that change costs and how to handle it. For whoever is exposing a graph.
The nested path problem
The canonical shape:
query {
me {
organisation {
members { # you may see colleagues
email # fine
auditLog { # you may not see this
entries { actor action object }
}
}
}
}
}
The top-level me is yours, the traversal is legitimate at every step, and the final field returns something the caller should not have. If the check for auditLog lives on the query that normally serves it rather than on the resolver itself, this path bypasses it.
The general rule that follows: authorise on the resolver, using the parent object and the viewer, not on the operation. A resolver must be safe reached from anywhere, because it will be.
Object-level, not just type-level
"May this user read documents" is the easy half. "May this user read this document" is where the bugs are, and in a graph it is easy to answer the first and skip the second because the object arrives as a parent rather than as an identifier the caller supplied.
The pattern that holds is to resolve authorisation against the object being returned, in the resolver, every time:
Document: {
content: (doc, _args, ctx) => {
if (!ctx.can(ctx.viewer, 'document:read', doc)) throw new Forbidden();
return doc.content;
}
}
Doing it once per sensitive field is repetitive, which is why people centralise it into directives or middleware. Centralising is right; the failure is centralising onto the operation rather than the field.
Batching and aliasing multiply one request
A single GraphQL request can contain many operations, and aliases let the same field be requested repeatedly:
query {
a: user(id: 1) { email }
b: user(id: 2) { email }
c: user(id: 3) { email }
# ... hundreds more
}
To your rate limiter that is one request. To your database it is hundreds of lookups, and to an attacker enumerating identifiers it is a single well-formed query.
Two consequences. Rate limiting by request count is meaningless, so limit by query cost: assign weights to fields, compute the cost before executing, and reject above a threshold. And any brute-force protection keyed on requests, such as login attempt limits, must count operations rather than requests if authentication is exposed through the graph.
Cap the number of operations per request and the number of aliases, and cap depth, because a recursive schema lets a small query expand enormously.
Introspection and error messages
Introspection in production hands over the entire schema, including fields your interface never calls. It is a convenience during development and a map afterwards. Disable it in production, or restrict it to authenticated internal callers, and be aware that disabling it is a hurdle rather than a control, since schemas can be inferred.
The related leak is error messages. A resolver that throws a detailed error, or a validation failure that says "unknown field x, did you mean y", reconstructs the schema for whoever is probing. Return a generic message externally and keep the detail in your logs with a correlation identifier.
The N+1 problem is also a security problem
Resolvers that fetch per item produce a query per element, which is a familiar performance issue and also a denial-of-service primitive when the list length is caller-influenced. Batching with a data loader fixes both, and the cost limit above is what stops the request that defeats the batching.
Check yours
# 1. Can a sensitive field be reached by a nested path?
# Write a query that arrives at it through a different parent and see.
# 2. Is introspection enabled?
curl -s -X POST https://api.example.com/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{__schema{types{name}}}"}' | head -c 200
# 3. Does aliasing bypass the rate limit?
# Send one request containing 200 aliased lookups and see whether
# it counts as one.
# 4. What does an unknown field return?
curl -s -X POST https://api.example.com/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ user { notARealField } }"}'
# a suggestion in the error message is a schema leak
Test one is the one that finds real authorisation bugs, and it needs someone who knows the schema to spend twenty minutes drawing paths.
The concession
Per-field authorisation is genuinely more work than per-endpoint, and a large schema with a check in every resolver is verbose, easy to forget in a new field, and hard to review. Teams that adopt GraphQL for developer velocity feel this immediately.
The mitigation is to make the default deny rather than to rely on diligence: a directive or middleware that requires every field to declare its authorisation, and a test that fails the build when a field has none. That converts the problem from remembering to declaring, which is the same move as deny-by-default routing in a REST service, and it is the only version that survives the schema growing.
The implication
GraphQL did not create new authorisation bugs. It moved the place they live, from one check per endpoint to one per field, and most teams brought their REST habits with them.
If your checks are on operations rather than on resolvers, the graph will eventually offer a path you did not think about. Drawing three of those paths by hand is the fastest way to find out.