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 answers "who is calling?". It belongs in the context function, which runs once per request before any resolver:
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 answers "may this caller do this?". A helper keeps resolvers readable:
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.
Repeating role checks in dozens of resolvers is error-prone. A custom directive moves the rule next to the field definition:
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:
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.
connectionParams in the graphql-ws context callback, since there are no HTTP headers per message.Why should the context function not throw when the bearer token is invalid?
user (or null) to every resolver.UNAUTHENTICATED where login is required instead of rejecting whole requests.@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.