Skip to content
advanced Phase 10 · Authentication and Authorization for GraphQL APIs

Role-based Field Access Control

Allowing or denying schema fields based on caller roles checked in resolvers.

50m
1 problems
Topic Progress 0%

Hidden Buttons Are Not Authorization

Hidden Buttons Are Not Authorization

The dashboard does not render a salary field for regular employees, so the team assumes payroll is protected. Then someone opens the network tab, copies an existing query, and adds one field:

query {
  me { name email }
  payroll { employeeId gross }
}

If Query.payroll has a resolver, it runs. The browser was never the enforcement point — it is a suggestion box. GraphQL makes this sharper than REST because the request itself declares which fields it wants, so a schema that contains a field and a resolver that serves it is the public API for that field, regardless of what any UI displays.

Two rules follow:

  1. Authorization lives in resolvers or schema directives. The client can strip a field from its queries; it cannot be trusted to leave one out.
  2. Deny by default. A new field added to the schema with no check is reachable by everyone — that is the default you get for free. The safe habit is to write the check first and ask which roles may read it before writing the resolver:
Query: {
  payroll: (_, __, ctx) => {
    requireRole(ctx, 'payroll.admin');       // throws before any data is read
    return payrollService.readAll();
  },
},

The test that matters is the one a client would write: send the introspected query with a low-privilege token and assert the field does not resolve. Roles must come from the verified claims placed in context during authentication — never from an argument, header, or field the request supplies. The construction of that identity is covered in request auth context.

It is worth understanding why this burden falls on you at all: the GraphQL specification defines execution and validation but says nothing about authorization, on purpose, because permission models differ per product. There is no @auth in the spec, no role argument reserved by the language, and no built-in notion of an owner. Whatever check you write is the entire permission system for that field, which is also why "the framework handles it" is never an accurate answer in a GraphQL codebase.

The same gap applies to mutations. A client that discovers:

mutation { promoteUser(id: "usr_9") { ok user { roles } } }

executes it with exactly the same ease as a query — mutations run serially and in full, so a missing check on a mutating field is a privilege escalation rather than a data read. Review mutation fields first when auditing a schema; they are the ones where a missed requireRole changes state.

Deny by Default, and What to Return When Denied

Deny by Default, and What to Return When Denied

Once a check exists, the next decision is what the caller sees. Three responses, three different meanings:

Response Meaning Cost
null for the field "no such data for you" reveals nothing, but see the non-null trap
errors[] with extensions.code: 'FORBIDDEN' "you are authenticated but not allowed" clear for clients, confirms the field exists
Field absent from the schema for this caller "this concept does not exist for you" strongest privacy, expensive to build

The non-null trap. If the field is declared non-null:

type Query { payroll: [EmployeePay!]! }   # null bubbles to the parent

returning null for a denied caller raises an error at the nearest nullable ancestor — which can null out a whole sibling payload the user was allowed to read. Sensitive fields should be nullable precisely so denial is a local, silent event.

The existence leak. Differences in response are information. Returning FORBIDDEN for id 7788 and NOT_FOUND for id 7789 tells an enumerator which ids exist, even if both return nothing else. Where guessing is realistic, prefer the same observable answer — identical message, identical extensions.code, comparable timing — for "not yours" and "does not exist". Object-level ownership checks belong with tenant data scoping, which covers the same problem between tenants.

Errors are for operations, not fields. Failing the whole operation with an error is right when the caller should not have run it at all. For a single field in an otherwise valid response, prefer null plus an optional partial error, and log the denial server-side with caller id, field, and request id so audits have a trail.

Central Guards and Consistent Role Checks

Central Guards and Consistent Role Checks

Role checks written inline in fifty resolver files will diverge: one file throws, another returns null, a third logs and continues, and the fourth forgets the check entirely. Centralize the decision and let resolvers merely call it:

// auth/rules.js — one place decides, one place logs
export function requireRole(ctx, role) {
  const roles = ctx.viewer?.roles ?? [];
  if (!roles.includes(role)) {
    logger.warn('denied', { userId: ctx.viewer?.id, role, requestId: ctx.requestId });
    throw new ForbiddenError('You do not have permission to read this field');
  }
}

export function can(ctx, role) {
  return (ctx.viewer?.roles ?? []).includes(role);
}

// resolver files stay declarative
Query: {
  payroll: (_, __, ctx) => { requireRole(ctx, 'payroll.admin'); return payroll.readAll(); },
  tickets: (_, __, ctx) => ticketService.listFor(ctx.viewer),   // scoped, not gated
},

Notice the two shapes. Gated fields are visible only to a role. Scoped fields are visible to everyone but each caller sees a different slice — usually a filter by tenant or owner rather than a hard deny. Most real schemas need both, and confusing them is how you end up with an endpoint that is "protected" but returns other people's rows.

Practical details that keep the policy honest:

  • Roles as arrays, checked by membership. ['user', 'admin'] beats a single string when users hold several roles; check the least surprising one first.
  • Fail closed on missing data. ctx.viewer?.roles ?? [] means a context bug denies instead of granting.
  • Test the helper once, test the wiring per field. A table-driven test over rules covers logic; a schema-level test asserts every sensitive field actually calls it.
  • When the rules are visible in SDL rather than in code, they become reviewable and generatable — the subject of authorization directives.

Practice Problems

0 / 1 solved
Spot the Authorization Hole
Field-level AuthZ Review

A schema exposes Query.employee(id) and Query.directory. The resolver checks the caller's role, fetches the record, then returns it. The UI only links to the directory for admins. Find every authorization defect and state what each attacker query would receive.

Example:

Input: Schema: employee(id: ID!): Employee, directory: [Employee!]! | Resolver: async (_, {id }, ctx) => { const e = await db.getEmployee(id); if (!ctx.viewer) throw new Error("unauthorized"); return e; } | Attacker token: roles ["intern"]

Output: Attacker receives the full Employee row for any id, including salary fields

The check only tests authentication (viewer exists), not role, and it runs before no ownership test — so any logged-in caller reads any id. Fetch-then-return also means the row left the database before any decision, so adding a throw afterward still leaks through timing and error differences.

Optimal Solution — O(1) time, O(1) space

Check role before the fetch, scope the query by tenant/owner in SQL, and field-gate sensitive columns — deny by default with identical responses for missing and not-owned records

// broken
Query: {
  employee: async (_, { id }, ctx) => {
    const e = await db.getEmployee(id);        // fetch before deciding
    if (!ctx.viewer) throw new Error('unauthorized');  // authn only, no role check
    return e;                                    // full row, any caller
  },
},

// fixed
Query: {
  employee: async (_, { id }, ctx) => {
    requireRole(ctx, 'people.reader');          // 1. deny by default, before I/O
    const row = await db.getEmployeeForTenant(  // 2. scope in SQL, not after
      id, ctx.viewer.tenantId
    );
    if (!row) return null;                      // 3. identical answer: missing or not yours
    return {
      id: row.id,
      name: row.name,
      salary: can(ctx, 'payroll.admin') ? row.salary : null,  // 4. field-level gate
    };
  },
},

Edge Cases:

  • Anonymous caller: denial must not depend on a null viewer being handled later in the code
  • Caller with the right role but a foreign id: SQL scope returns nothing and the response matches the nonexistent-id case
  • Salary gated per field while the rest of the record stays readable: field checks, not whole-object denials

Quiz

1. A sensitive field is never rendered by the UI. What happens when a client queries it directly?

Question 1 options

2. A denied field is declared non-null and the resolver returns null. What does the client observe?

Question 2 options

3. A request for another user's record returns FORBIDDEN while a nonexistent record returns NOT_FOUND. What have you exposed?

Question 3 options

Flashcards

Question

Where field authorization lives

Answer

In resolvers, central helpers, or schema directives — never in the client UI, which only controls presentation.

Question

Deny by default

Answer

A field with no check is public. Write the access rule before the resolver so new schema surface starts closed.

Question

Gated vs scoped fields

Answer

Gated: only a role sees the field. Scoped: everyone sees the field but with their own tenant/owner slice.

Question

Non-null denial trap

Answer

Returning null for a denied non-null field bubbles the error up and can null allowed sibling fields — keep sensitive fields nullable.

Question

FORBIDDEN vs NOT_FOUND

Answer

Different answers for owned vs nonexistent records leak existence; prefer identical responses when ids are guessable.

Revision Notes

Key Takeaways

  • 1. The schema plus a resolver is the API; anything not denied server-side is readable by anyone who can query
  • 2. Centralize role logic in helpers so one policy, one log line, one place to change
  • 3. Denial responses are observable — nullability, error codes, and timing all leak information if they differ

Interview Tips

  • • Answer "who checks authorization?" with a location and a code snippet, not with "the middleware handles it"
  • • Show you know the difference between gating a field and scoping its results — most candidates conflate them

Cheat Sheet

Field Access Control Cheat Sheet

Placement: resolver check or schema directive — client hiding is UX only

export function requireRole(ctx, role) {
  const roles = ctx.viewer?.roles ?? [];        // fail closed
  if (!roles.includes(role)) throw new ForbiddenError('...');
}

Deny response matrix

Case Response
Field denied null (keep the field nullable)
Operation denied errors[] + extensions.code
Not yours vs missing identical answer when ids are guessable

Gated vs scoped: role gate for sensitive fields · tenant/owner filter for shared lists

Checklist for every new field: who may read it? what does denial return? is it covered by a test that queries it with a low-privilege token?

Continue Learning