GraphQL path · In production · Topic 7 of 12

Authorisation at the field level

Not started

GraphQL separates the two halves of access control more sharply than most APIs. **Authentication** (who is this?) happens once per request, before execution, usually in HTTP middleware that checks a session or token and puts the verified user into the request's context. **Authorisation** (may they see this field of this object?) has to happen wherever data is reached, because a graph has many paths to the same data. The guidance from graphql.org is to keep those rules in the business logic layer that resolvers call, so the same rule applies whether the data is reached through GraphQL, a REST endpoint or a background job.

Why it matters: In a graph, the same object can be reached by many paths. A check on one entry point protects that path only; the rule has to travel with the data, or someone will find another way in.

  1. 1Authentication before execution

    Verify the token or session in the HTTP layer and put the resulting user in `context`. Resolvers should never parse credentials themselves.

  2. 2Many paths, one rule

    `me.email`, `post.author.email` and `search` results all reach `User.email`. Enforce the rule where that data is loaded, not on one root field.

  3. 3Put rules in the business layer

    Resolvers call functions such as `users.getEmail(viewer, user)`; the same functions serve REST endpoints and jobs, so every entry point agrees.

  4. 4Deny without leaking

    A hidden field can return null with an error, or the object can look as if it doesn't exist. Pick per field, and be consistent.

1

How it works

The explanation, with examples you can run.

Many paths, one field

type Query {  me: User  post(id: ID!): Post  search(text: String!): [SearchResult!]!}type Post { author: User! }type User { id: ID! name: String! email: String }

Three different routes lead to User.email. A check on Query.me protects only the first:

⌘/Ctrl + Enter
OutputNot run yet

Press Run to see what your code prints, in order.

Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.

Where the rule lives

// users.js: the business layer, shared by every entry pointexport async function getEmail(viewer, user) {  if (!viewer) return null  if (viewer.id === user.id || viewer.roles.includes("support")) return user.email  return null} // resolvers.jsconst resolvers = {  User: { email: (user, _args, ctx) => users.getEmail(ctx.viewer, user) },}

Pass the business layer a fully loaded user, not a raw token, so it can decide on roles and ownership. Some servers offer schema directives such as @auth(requires: ADMIN); they are a convenient way to attach rules to fields, but they still need to call the same business logic, and they can't express ownership rules ("authors see their own drafts") without it.

Patterns that help

  • Filter lists at the source. A resolver that loads 100 posts and then hides 60 has paged wrongly; ask the data layer for "posts this viewer may see".
  • Mind batching. A DataLoader keyed only by id returns whatever it loaded first; since loaders are per request and per viewer, that is fine, but never share one between users.
  • Mutations need checks too, on the object being changed, not only on the operation: updatePost(id) must confirm this viewer owns that post.
  • Don't confirm existence. For objects the viewer can't see, returning null is often safer than an error that says "forbidden".
2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.`Query.adminStats` checks for the admin role. `User.salary` has no check, and is reachable via `team.members`. Is salary protected?
  2. 2.Where should the server read the bearer token?
  3. 3.A user asks for `post(id: "p9")`, a draft they may not see. A common, safe answer is:

0 of 3 right so far. Get all three to finish this topic.