Authentication & Authorization in GraphQL

GraphQL — Authentication & Authorization in GraphQL
GraphQL
Chapter 8 · Authentication & Authorization in GraphQL

🔐 Authentication & Authorization in GraphQL

Chapter 7 covered throwing an UNAUTHENTICATED error. This chapter is about implementing auth properly — authentication (who's asking) and authorization (what they're allowed to see) in an API with exactly one endpoint, building on the session/JWT material from the site's Authentication & Session Security course.

Authentication Happens Once, in Context

Unlike REST, where each route can independently apply its own auth middleware, GraphQL has one endpoint — so authentication happens exactly once, inside the context function from Chapter 6:

async context({ req }) { const authHeader = req.headers.authorization ?? ""; const token = authHeader.replace("Bearer ", ""); const currentUser = verifyToken(token); // null if missing/invalid return { currentUser, userLoader: createUserLoader(pool), // Chapter 5 }; }

Every resolver in the request now sees the same context.currentUser — either a real user or null — without needing its own separate auth check to establish who's asking.

🎯 Authorization Happens Per Field

A single query can mix public and protected fields, so authorization can't live in one central gate the way REST middleware does — it belongs inside whichever specific resolvers actually need it:

A Field Only Visible to the Owner or an Admin

User: { email: (parent, args, context) => { const isOwner = context.currentUser?.id === parent.id; const isAdmin = context.currentUser?.role === "ADMIN"; return (isOwner || isAdmin) ? parent.email : null; // silent null, not an error }, }

Return null vs Throw

Returning null for a nullable field lets the rest of the query still succeed — good for "you're just not allowed to see this one thing." Throwing (Chapter 7) is clearer but forces the client to specifically handle the error.

Reusable Authorization Helpers

Rather than repeating checks in every resolver, wrap them: requireAuth(context), requireOwnerOrAdmin(context, ownerId) — called at the top of any resolver that needs them.

function requireAuth(context) { if (!context.currentUser) { throw new GraphQLError("Not authenticated", { extensions: { code: "UNAUTHENTICATED" } }); } return context.currentUser; } Mutation: { deletePost: (parent, args, context) => { const user = requireAuth(context); // reused, not repeated return db.posts.delete(args.id, user.id); }, }

One Gate vs Many Small Checks

REST

Auth middleware runs once, before a route handler — the whole endpoint is protected or it isn't.

GraphQL

Checks are scattered across whichever resolvers need them, since one query commonly mixes public and protected fields in a single request.

Some libraries offer schema-level @auth-style custom directives as an alternative to hand-written per-resolver checks — worth knowing exists, though this course sticks to explicit resolver-level checks for clarity.

💻 Coding Challenges

Challenge 1: Write requireOwnerOrAdmin

Write a reusable requireOwnerOrAdmin(context, resourceOwnerId) function that throws a FORBIDDEN-coded GraphQLError unless context.currentUser is either the resource's owner or has an ADMIN role.

Goal: Practice generalizing the chapter's inline ownership check into a reusable helper.

→ Solution

Challenge 2: Choose Null vs Throw

For each of (a) a User.email field hidden from non-owners, and (b) an updatePost mutation called by someone who doesn't own the post, recommend returning null or throwing, and justify each choice.

Goal: Practice applying the null-vs-throw tradeoff to two different kinds of operations.

→ Solution

Challenge 3: Find the Unprotected Path

A schema checks authorization only inside Query.user's resolver, assuming this protects every User field from unauthorized access. Explain a different query path that reaches the same User data without ever running that check.

Goal: Practice recognizing that the same type can be reached through multiple different resolver paths.

→ Solution

⚠️ Gotcha: Protecting One Entry Point Isn't Protecting the Data

It's tempting to add an authorization check to Query.user's resolver and consider User data protected — but that's only one path to reach a User object. Query.postsPost.author returns full User objects too, through a completely different resolver that never runs Query.user's check at all. In GraphQL, the same type is frequently reachable through many different nested paths, and a check placed on one entry point does nothing to protect any of the others. Authorization needs to live on the field actually returning sensitive data (like User.email in this chapter's own example) — not just on whichever root query happens to be the "obvious" way in.

🎯 What's Next

Every piece is now in place — the final chapter is a capstone: Building a Small GraphQL API, combining the schema, resolvers, mutations, DataLoader, and auth from every chapter into one working service.