Authorization
|
This section documents the current GraphQL specification (October 2021), plus the working draft for
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as GraphQL tooling iterates quickly. |
GraphQL’s single endpoint and client-selected field sets mean access control cannot live in routing the way it
often does for REST — there is no /admin/* path to guard. This page covers where authentication and
authorization decisions belong in a GraphQL server: authenticating once before execution begins, choosing
between domain-layer, policy-layer, and schema-directive enforcement, and the per-type/per-field granularity
that decision implies.
Authenticate before execution, not inside it
Authentication — establishing who is calling — is a transport-level concern that should be settled
before the GraphQL engine ever parses a query, not something a resolver figures out mid-execution. A typical
server sits an authentication middleware (session cookie, bearer token, mTLS, whatever the transport uses) in
front of the /graphql endpoint, and that middleware’s only GraphQL-specific job is to place the result — the
authenticated viewer, or an explicit "anonymous" marker — onto the per-request context object that every
resolver receives as its third argument:
async function graphqlContext(req) {
const token = req.headers.authorization?.replace(/^Bearer /, '');
const viewer = token ? await sessions.verify(token) : null; // null, not a thrown error, for anonymous
return { viewer, db, loaders: createLoaders() };
}
Every resolver on every field can then read context.viewer without knowing anything about tokens, cookies, or
sessions — and, critically, without needing to authenticate again. Doing authentication per-resolver instead
would mean re-verifying a token on every field a query happens to touch, duplicating the same check dozens of
times per request and inviting the checks to drift out of sync with each other. context is also where a
data loader per request typically lives (see
Performance and N+1) — the viewer and the loaders are
both request-scoped, and both get threaded through the same object for the same reason.
A request that fails authentication outright (a malformed or expired token, when the schema requires one) is
usually rejected at the transport layer with an HTTP 401 before a GraphQL response is even produced; a request
that authenticates successfully but as an anonymous viewer is allowed to reach execution, where individual
fields decide for themselves whether an anonymous viewer may proceed. That distinction — reject at the door
versus admit and let each field decide — is what makes public and viewer-scoped fields coexist in the same
schema without every field re-implementing its own login check.
Where authorization decisions belong
Authorization — deciding what an already-identified viewer may do — can live in one of three places, and the choice affects how consistently the rule is enforced across every path that reaches the same data:
| Layer | What it looks like | Trade-off |
|---|---|---|
Domain/service layer (preferred) |
The service or repository method a resolver calls — |
Enforced no matter which resolver, cron job, or CLI command calls the service; a resolver forgetting to check cannot leak data, because the layer beneath it still refuses. |
Policy/per-field layer |
A dedicated authorization function (an ability check, a policy object, or a CASL/Oso-style engine) is invoked explicitly at the top of a resolver, or wraps it. |
Keeps the rule visible right next to the field it protects, at the cost of depending on every resolver author to remember to call it. |
Schema directive ( |
The rule is declared declaratively in the SDL and enforced by a directive implementation that wraps the underlying resolver automatically. |
Impossible to forget for a field once the directive is applied to it — the schema itself won’t compile the field without deciding whether |
The three are not mutually exclusive: a well-factored server puts the authoritative check in the domain layer — so nothing can bypass it by calling the service from somewhere other than a resolver — and uses a policy
function or \@auth directive as a fast-fail convenience at the GraphQL boundary, rejecting an obviously
disallowed request before it even reaches the service and before any related fields resolve needlessly.
Treating the GraphQL-layer check as the only check is the fragile pattern this page argues against in the
next section.
Per-type vs. per-field authorization
Authorization granularity in a GraphQL schema comes in two shapes, and most real schemas mix both:
-
Per-type checks gate access to an entire object type or a root field that returns it — an
adminReportquery, or every field of anAdminSettingstype, requires the same role regardless of which sub-field a particular query happens to select. This is the coarser, cheaper-to-reason-about option: one check per type (or per root field), applied once. -
Per-field checks gate individual fields within an otherwise-accessible type — any authenticated user can query a
User, but only the viewer themselves (or an admin) may read thatUser’s `emailfield, whiledisplayNameon the same type stays open to everyone. This is the finer-grained option GraphQL’s field-by-field execution model makes natural, and it is also where authorization logic is easiest to scatter across many small, easily-missed checks if it isn’t centralized.
Reaching for per-field checks by default, even where a per-type check would do, tends to multiply the number of authorization call sites a schema accumulates without a matching gain in safety; reserve per-field checks for fields that genuinely need a different rule from their parent type, and let everything else inherit the type-level decision.
Why authorization-only-in-resolvers gets fragile
Putting every authorization decision directly inside resolver functions — rather than in the domain layer beneath them — tends to degrade for three concrete reasons as a schema grows:
-
Every new path to the same data needs its own copy of the check. A
Bookmight be reachable as a rootquery.book(id)field, asAuthor.books, and asSearchResult.books— three resolvers, three chances to forget (or subtly diverge in) the same rule. -
Field-level resolution runs deep into a query, past the point where rejecting cheaply would help. Because execution walks the whole selection tree, an authorization failure discovered several levels deep still means the server did the work to reach that depth first; a check placed once in the domain layer that a shallower field already calls into fails fast, before deeper fields are even resolved.
-
Resolver code and business logic end up interleaved, which makes both harder to test in isolation — testing "can a non-owner update this order" now requires exercising the whole GraphQL execution pipeline instead of calling
orderService.updatedirectly with a non-owner viewer.
None of this means resolvers should never reference an authorization result — they very often need to, in order to decide whether to call a service at all — only that the check’s source of truth should sit in the domain/service layer that every caller shares, with the resolver treated as one caller among several rather than the only enforcement point.
A field-level authorization check
The following schema exposes an order query and an Order.internalNotes field that only the order’s owner or
an admin may read — the query itself is open to any authenticated viewer, but one of its fields is not:
type Query {
order(id: ID!): Order
}
type Order {
id: ID!
status: OrderStatus!
total: Float!
internalNotes: String # owner or admin only; null for anyone else, not an error
}
A resolver for internalNotes calls into the same authorization decision the domain layer already owns, rather
than re-deriving the rule itself, and returns null for a viewer who simply isn’t allowed to see the field — distinct from the request-level rejection an unauthenticated caller gets for the whole query:
const resolvers = {
Order: {
internalNotes(order, args, context) {
const { viewer } = context;
if (!authz.can(viewer, 'read:internalNotes', order)) {
return null; // silently omit; use `context.viewer` presence to distinguish from "not signed in"
}
return order.internalNotesText;
},
},
Query: {
order(parent, { id }, context) {
if (!context.viewer) {
throw new AuthenticationError('You must be signed in to view this order.');
}
return orderService.get(id, context.viewer); // service enforces "can this viewer see this order at all"
},
},
};
Note the asymmetry: the root order field throws when there is no viewer at all (an authentication failure,
not specific to this field), while internalNotes — reached only once a viewer already exists and the base
Order was already authorized — resolves to null rather than throwing, because a field-level authorization
gap is not a failure of the whole request, just an absent piece of data for this particular viewer. The next
section covers when a null versus a thrown error is the right choice in more general terms.
Surfacing authorization failures as errors
When an authorization check does need to fail the request outright — as opposed to quietly returning null
for one field — it becomes a normal GraphQL error, and the ecosystem converges on two extensions.code values
for the two distinct failures: UNAUTHENTICATED for "no valid viewer at all" and FORBIDDEN for "a valid
viewer who is not allowed to do this":
{
"errors": [
{
"message": "You must be signed in to view this order.",
"path": ["order"],
"extensions": { "code": "UNAUTHENTICATED" }
}
]
}
Response and error handling covers the errors array’s
full shape, the extensions.code convention in general, and how it interacts with GraphQL’s partial-success
model; Mutations covers the related but distinct case of an authorization
failure that is instead modelled as ordinary payload data (a userErrors entry) when the schema treats "you
can’t do that" as an expected business outcome rather than a protocol-level failure.
Schema directives as a declarative front door
A \@auth schema directive, as introduced in
Directives, is the declarative face of the policy-layer approach: the SDL
states the requirement, and a directive implementation supplies the actual check by wrapping the field’s
resolver before it ever runs:
directive @auth(role: String!) on FIELD_DEFINITION
type Query {
order(id: ID!): Order @auth(role: "AUTHENTICATED")
adminReport: Report! @auth(role: "ADMIN")
}
This makes the requirement visible to anyone reading the schema — including through introspection, so a schema-aware tool can flag which fields are gated without reading server code — but the directive still needs a framework-specific implementation to do anything, and (per the trade-off table above) it should still defer to the domain layer as the check’s actual source of truth rather than embedding the authorization rule inside the directive resolver itself.
|
This page is generated with AI assistance. Verify directive-based and layered authorization patterns against graphql.org/learn/authorization and, for a specific server framework’s directive-implementation hook, that framework’s own documentation before relying on it in production. |
Authorization vs. demand control
Authorization answers "is this viewer allowed to access this data or perform this action" — a yes/no decision per field or per type. It is a distinct concern from demand control: limiting how much work a single, otherwise fully authorized query is allowed to force the server to do (depth limits, cost analysis, rate limiting, execution timeouts), covered in Security and demand control. A query can be entirely authorized — every field it touches is one the viewer may legitimately read — and still be worth rejecting because it is too deep, too broad, or too expensive to execute; conversely, a shallow, cheap query can still be completely unauthorized. Treating the two as the same problem tends to produce a server that either under-limits expensive-but-legal queries or over-restricts legal-but-cheap ones.
Related pages
-
Directives — declaring and applying a custom
\@authdirective, and where its enforcement logic actually has to live. -
Execution and resolvers — the resolver signature (
parent, args, context) that carries the viewer through to every field. -
Response and error handling — the
errorsarray shape and theextensions.codeconvention used byUNAUTHENTICATED/FORBIDDEN. -
Mutations — modelling an authorization failure as
userErrorsdata instead of a top-level error, when that fits the schema’s conventions better. -
Security and demand control — limiting how expensive an already-authorized query is allowed to be.
-
Performance and N+1 — the same request-scoped
contextobject used here to carry the viewer also typically carries per-request data loaders.