Authentication and Authorization

Advanced
14 min

Authentication and Authorization

GraphQL has a single endpoint, so protecting routes as in REST does not apply. Instead, the server identifies the caller once per request and every resolver decides what that caller may see or change. After this lesson you will be able to verify a token in the context function, enforce login and ownership rules in resolvers, and declare role requirements in the schema with a custom directive.

Authentication Happens in the Context Function

Authentication answers "who is calling?". It belongs in the context function, which runs once per request before any resolver:

javascript
import jwt from "jsonwebtoken"; export async function buildContext({ req }) { const header = req.headers.authorization ?? ""; const token = header.startsWith("Bearer ") ? header.slice(7) : null; let user = null; if (token) { try { const payload = jwt.verify(token, process.env.JWT_SECRET); user = await db.users.find(payload.sub); } catch {} // invalid or expired token: continue as anonymous } return { db, user }; }

Wire it in with startStandaloneServer(server, { context: buildContext }) or expressMiddleware(server, { context: buildContext }). An invalid token does not throw here: public fields should still work for anonymous callers, and rejecting the whole request would block a query that mixes public and private fields. The token itself comes from a login mutation or a session cookie.

Authorization Happens in Resolvers

Authorization answers "may this caller do this?". A helper keeps resolvers readable:

javascript
import { GraphQLError } from "graphql"; export function requireUser(ctx) { if (!ctx.user) { throw new GraphQLError("Not authenticated", { extensions: { code: "UNAUTHENTICATED" } }); } return ctx.user; } const resolvers = { Query: { me: (_p, _a, ctx) => requireUser(ctx), }, Mutation: { deletePost: async (_p, { id }, ctx) => { const user = requireUser(ctx); const post = await ctx.db.posts.find(id); if (post.authorId !== user.id && user.role !== "ADMIN") { throw new GraphQLError("Not allowed", { extensions: { code: "FORBIDDEN" } }); } await ctx.db.posts.delete(id); return true; }, }, User: { email: (user, _a, ctx) => (ctx.user?.id === user.id ? user.email : null), }, };

Three levels appear here: an operation that requires login (me), an ownership check (deletePost), and a field-level rule (User.email is visible only to its owner). Field-level checks let the same User type appear safely in public and private queries.

Declaring Roles in the Schema

Repeating role checks in dozens of resolvers is error-prone. A custom directive moves the rule next to the field definition:

graphql
directive @auth(requires: Role = USER) on FIELD_DEFINITION | OBJECT enum Role { USER EDITOR ADMIN } type Mutation { publishPost(id: ID!): Post! @auth(requires: EDITOR) }

mapSchema from @graphql-tools/utils implements it by wrapping each annotated field's resolver:

javascript
import { mapSchema, getDirective, MapperKind } from "@graphql-tools/utils"; import { defaultFieldResolver } from "graphql"; const rank = { USER: 1, EDITOR: 2, ADMIN: 3 }; export const authDirective = (schema) => mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (field) => { const auth = getDirective(schema, field, "auth")?.[0]; if (!auth) return field; const { resolve = defaultFieldResolver } = field; field.resolve = (src, args, ctx, info) => { if (rank[requireUser(ctx).role] < rank[auth.requires]) { throw new GraphQLError("Not allowed", { extensions: { code: "FORBIDDEN" } }); } return resolve(src, args, ctx, info); }; return field; }, });

Apply it once: authDirective(makeExecutableSchema({ typeDefs, resolvers })), and pass the result as schema.

Tips

  • Keep business rules in a service layer shared by every entry point; the directive is for coarse role gates.
  • For subscriptions over WebSockets, read the token from connectionParams in the graphql-ws context callback, since there are no HTTP headers per message.
Quick Quiz
Question 1 of 2

Why should the context function not throw when the bearer token is invalid?

Key Takeaways

  • Authenticate once per request in the context function and expose user (or null) to every resolver.
  • Let resolvers throw UNAUTHENTICATED where login is required instead of rejecting whole requests.
  • Authorize in resolvers at operation, object and field level, next to the data access.
  • A custom @auth directive implemented with mapSchema centralises role requirements in the SDL.

Next lesson: Subscriptions: Real-Time Data over WebSockets — push events from the server to connected clients with graphql-ws.

Authentication and Authorization - GraphQL | CodeYourCraft | CodeYourCraft