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:
- Authorization lives in resolvers or schema directives. The client can strip a field from its queries; it cannot be trusted to leave one out.
- 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
Quiz
1. A sensitive field is never rendered by the UI. What happens when a client queries it directly?
2. A denied field is declared non-null and the resolver returns null. What does the client observe?
3. A request for another user's record returns FORBIDDEN while a nonexistent record returns NOT_FOUND. What have you exposed?
Flashcards
Question
Where field authorization lives
Click to reveal answer
Answer
In resolvers, central helpers, or schema directives — never in the client UI, which only controls presentation.
Question
Deny by default
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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?