GraphQL doesn't have an opinion on authentication. There's no login field in the spec, no auth header defined by the language, no concept of a "logged in" request at the query-execution level. Authentication happens in the HTTP layer — same as any other web API — and what GraphQL gives you is a single place, the resolver context, to make the result of that authentication available everywhere it's needed. Most of the confusion I see comes from treating GraphQL as if it should handle auth for you rather than just handing you a clean seam to plug it into.
Building the context object
Every mainstream GraphQL server — Apollo Server, GraphQL Yoga, Mercurius — gives you a hook that runs once per incoming request, before any resolver executes, where you build a context object. This is where auth actually happens: you read the Authorization header (or a session cookie), verify the token, look up the user, and hang the result on context. Every resolver in that request then receives the same context object as its third argument.
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
const authHeader = req.headers.authorization || '';
const token = authHeader.replace('Bearer ', '');
const user = token ? await verifyToken(token) : null;
return { user };
},
});
The important part is that this runs once per request, not once per field. If you're verifying a JWT or hitting a session store, do it here — resolvers should read context.user, not each independently re-parse the header.
Checking authorization inside resolvers
Authentication tells you who the caller is; authorization decides what they're allowed to see or do, and that check belongs in the resolver for the field being protected, not at a gateway in front of the whole schema. A gateway can reject an entire request, but it has no visibility into which of the twenty fields in the query is the sensitive one. If salary and jobTitle live on the same Employee type, only a resolver sitting on salary can decide that field-by-field.
A gateway sees a request; a resolver sees a field. Field-level permissions — "this user can read jobTitle but not salary" — can only be enforced where the field is actually resolved. Push coarse checks (is there a valid session at all) to the edge, and push fine-grained checks (can *this* user see *this* field on *this* object) into the resolver that owns it.
Directive-based authorization
Repeating an if (!context.user) throw new Error(...) at the top of every resolver gets tedious and easy to forget on a new field. A common pattern is a custom schema directive that wraps the check declaratively in the SDL, then transforms the schema at startup to inject the guard automatically.
directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role { USER ADMIN }
type Employee {
id: ID!
jobTitle: String
salary: Float @auth(requires: ADMIN)
}
The directive itself does nothing by default — you implement its behavior in a schema transformer (e.g. with @graphql-tools/utils's mapSchema) that wraps the resolver for any field carrying @auth and throws before the underlying resolver runs if context.user lacks the required role. This keeps the permission visible in the schema itself rather than buried in resolver logic someone has to go read.
The nested-resolver leak
The most common real bug: a top-level query checks permissions correctly, but a nested field on the returned type doesn't re-check anything, because it was written assuming it would only ever be reached through the "safe" parent. GraphQL doesn't work that way — any type can be reached through multiple paths in the graph, and a resolver that trusts its caller inherits nothing from how it was reached.
If Order.customer resolves to a User, and User.email has no guard because "you'd only see a user through an order you own," that assumption breaks the moment User is also reachable from adminSearchUsers(query: String). Put the check on the field that exposes the sensitive data, not on the query that you assumed was the only path to it.
Subscriptions authenticate differently
Queries and mutations ride ordinary HTTP requests, so the context-per-request pattern above just works. Subscriptions over WebSocket are a different transport with a different lifecycle: the connection is established once and then stays open for many events, so there's no per-message Authorization header to read. Instead, the client sends credentials in the connection params during the WebSocket handshake (connectionParams in graphql-ws), and the server validates them in an onConnect callback, building a context that's then reused for every subscription event pushed down that socket. Treat this as a separate auth path to test — it's easy to lock down HTTP auth thoroughly and forget the WebSocket handshake accepts anything.
Wrapping up
Auth in GraphQL is ordinary auth, just relocated: verify identity once in the context function, then push authorization decisions down to the resolvers and directives that actually own the sensitive fields. The failure mode worth watching for isn't the top-level query — it's the nested type reachable through a second, less-guarded path you didn't think about when you wrote the first resolver.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.