Guide 126 worked guide

How to review GraphQL authorization at the field level

Review GraphQL authorization at fields and relationships, including new type paths and error projections that can reveal values denied by the main response.

Review GraphQL authorization by asking, for every sensitive field a client can select, who may read or change this value on this particular object, through every path to that object? Start with the schema, then trace root fields, nested relationships, node lookups, batch loaders and mutations into the business operation that enforces that rule. Authentication at the single GraphQL HTTP endpoint does not answer the field question. A permitted parent object also does not automatically make every property of that object permissible.

The answer changes with policy. A public product name might be readable by anyone, while a customer contact field is restricted to a current account member and an internal cost field to finance staff. Record the policy first, because the same resolver code could be safe under one rule and wrong under another. GraphQL's own authorization guidance recommends a single source of truth in the business logic layer, called by resolvers. OWASP's GraphQL guidance says to check both edges and nodes.

Illustrative case: a nested invoice field

Illustrative application and people: Acorn Billing has a GraphQL schema with Account, Invoice and Customer types. An account member may see the invoices for their account. Only billing managers may see an invoice's payment contact email. A support employee may see a customer name for a ticket but may not see that customer's invoices without a separate billing permission. No real customer system or request is described here.

The invoices query checks account membership before returning invoice IDs. The Invoice.paymentContact resolver then loads an email by invoice ID without checking the selected account or role. A second root query, node(id), can return the same Invoice type by a global ID. That route performs a generic “signed in” check. These are two different access questions: may the actor reach the invoice, and may they read this field on it? A source citation to the email resolver supports only that the resolver loads an email. It does not prove that unauthorized callers can reach it in production.

A simple review artifact makes the intended rule visible:

Selection path Object rule Field rule Decisive server check Open question
account → invoices → number Active member of this account Invoice number visible to member Invoice lookup bound to server-selected account Does a loader keep that scope?
account → invoices → paymentContact Active member of this account Billing manager only Field read with current role and invoice owner Is the check in business logic?
node(id) → Invoice → paymentContact Same invoice owner rule Billing manager only Node lookup plus field check Can a generic ID load a foreign invoice?
customer → invoices → paymentContact Customer visibility alone is insufficient Billing manager only Invoice edge and field checks Does support view bypass the invoice edge?

The matrix is not a generic “protect PII” reminder. It defines actor, object and property at each path. OWASP's API3:2023 covers unauthorized read or write of object properties, including properties requested through GraphQL.

Walk the schema into the business rule

1. Mark fields that need a decision

Input: the schema and the product's access rules. Owner: developer with the policy owner. Output: a small field policy matrix like the one above.

Mark money, private contact data, internal notes, ownership identifiers, permission fields and any relationship that exposes objects from another account. Also mark fields that change these values in mutations. A field may be sensitive because of context even when its scalar type is ordinary text. A customer name visible on one screen may be confidential when it is attached to an invoice from another account.

Do not infer the policy from the UI. Hidden fields may still be selectable. The policy owner should state whether the rule is tied to an account, object, role, purpose or a combination. Unknown policy is a review blocker, not an implicit allow.

2. List every way to obtain the type

For Acorn's Invoice, list account.invoices, customer.invoices, node(id), search results, mutations that return an Invoice, subscriptions and any new schema field that returns it. Follow interfaces and unions where they permit a client to select the same concrete field. GraphQL requests can ask for nested values through one HTTP request; GraphQL's HTTP guidance places field authorization during execution by business logic called from resolvers, after authentication.

Decision point: is access enforced only in one root query, or in the shared business operation that all paths use? If the latter, inspect the contract of that operation: does it require a current actor, selected account, action and object ID, or can a caller omit those inputs? A directive in the schema can help route an authorization decision, but the directive's presence is not proof that the business rule runs on every path.

3. Trace a nested field and its loader

For paymentContact, trace resolver → loader → repository query → returned field. Find where the invoice owner comes from. It must be the stored invoice relationship checked against trusted current context; a client-supplied account argument is only a selector. Check how the loader is keyed and scoped. If one request can load invoices from different accounts, a cache keyed only by invoice ID may be suitable for raw records only when every use, including cache hits, still authorizes the current actor/account and field; a cross-request cache containing actor-specific projections needs stricter separation. A request-scoped loader is useful, but is not itself an authorization control if one request can change account context. Do not mark a loader safe merely because its caller checked an unrelated parent.

Examine failure behavior. A denied field must not appear through another resolver, error message, log exposed to the client, or an alternate serialized representation. GraphQL can return partial data when a field fails; inspect the actual behavior of the chosen implementation and schema nullability. A denied field that makes an entire parent null may be acceptable, but that behavior should be deliberate and documented.

4. Inspect writes and returned objects

A mutation such as updateInvoiceContact needs two decisions: may this actor edit the contact for this invoice, and may the returned selection expose the updated or adjacent fields? Validate the input fields that the client is allowed to change. Mapping arbitrary input properties into a stored object is a separate property-level risk described by OWASP API3. An authorized mutation is not a blanket read grant on its return type.

Record whether a failed mutation leaves state unchanged. If an event, background job or subscription publishes the result, review that delivery path as its own access boundary rather than inheriting the request's decision forever.

5. Seek counterevidence and validate safely

Read current policy code, resolver mappings, loader behavior and selected deployment configuration. A useful falsifier for the illustrative concern is: “Every route returning Invoice invokes the same business operation, which verifies invoice ownership and billing permission before paymentContact is read.” If that is supported, the suspected gap may be rejected. If only one route is protected, narrow the claim to the unprotected route and document the remaining unknowns.

Where the team has an authorized test environment with synthetic accounts, compare an allowed billing manager, a same-account ordinary member and a separate-account member. Ask for the same field through each relevant path, including a nested route and a node lookup. Confirm both response content and side effects. These are proposed review checks; no request or exploit was performed for this example. A source-only review may establish that a check exists, but deployment-specific behavior and data-loader configuration can require separate validation.

Make the selection and denial visible

Here is a deliberately small synthetic schema fragment, using GraphQL's September 2025 specification. It is not a complete server or a framework recipe:

type Invoice { id: ID!, number: String!, paymentContact: String }
type Account { invoices: [Invoice!]! }
type Query { account: Account, node(id: ID!): Invoice }
type Mutation { acknowledgeInvoice(id: ID!): Invoice }

An ordinary member may acknowledge their own invoice, but cannot read its payment contact. These three selections must therefore reach the same field rule:

query Nested { account { invoices { number paymentContact } } }
query Direct { node(id: "invoice-7") { number paymentContact } }
mutation Ack { acknowledgeInvoice(id: "invoice-7") { number paymentContact } }

In the flawed design, the nested query passes membership and the field resolver returns row.paymentContact. An entitled member receives the synthetic address billing@example.invalid. Adding a billing-manager check only to account.invoices would also block invoice numbers the member is entitled to read, while leaving the node and mutation paths unchanged.

Instead, each object path resolves an invoice under the current account and action. The shared sensitive-field operation then makes its own decision:

Invoice.paymentContact(row, context):
  return billing.readPaymentContact(context, row.id)

readPaymentContact(context, invoiceId):
  invoice = loadInvoiceForCurrentAccount(context, invoiceId)
  requireCurrentCapability(context, "billing.contact.read", invoice)
  return invoice.paymentContact

For this example, denial raises a sanitized field error. Because paymentContact is nullable, the chosen expected direct-query response is:

{
  "data": {"node": {"number": "INV-7", "paymentContact": null}},
  "errors": [{"message": "Not permitted", "path": ["node", "paymentContact"]}]
}

This is an expected response shape, not captured output. The GraphQL specification defines field-error handling and propagation through non-null positions. Changing this field to String! changes where null propagates; it does not confer permission. Verify the actual server's error masking and HTTP behavior separately.

The resulting decision matrix is concrete:

Caller and path Object outcome Contact outcome
Same-account ordinary member, nested or node Invoice number permitted Null plus sanitized denial in this design
Same-account billing manager, nested or node Invoice permitted Contact permitted
Foreign-account member, node Object denied before protected fields No contact or invoice number
Ordinary member, acknowledge mutation Authorized acknowledgement may succeed Return selection still denies contact
Support customer view Customer name alone does not authorize invoice edge Unresolved until that edge is inspected

Do not interpret the mutation's field error as proof that the acknowledgement failed: the write and the selected return fields have different decisions. The client contract must explain that distinction. Likewise, a loader cache should not turn a previous manager's authorized object into a member's grant. Check the request lifetime, key and mandatory business operation together; there is no universally secure cache key independent of policy.

At this point the reviewer can reject “parent membership protects the contact” and hand the shared field operation to its owner. The support edge and deployed resolver bindings remain explicit follow-ups. No real field access or independent review is claimed by the example.

Treat schema growth as a new access path

Acorn may later add a search result or mutation that returns Invoice. The type's existing field policy should still govern paymentContact; a new parent resolver must not become a shortcut around it. Add the new path to the matrix and inspect both object eligibility and sensitive-field access.

Also check the denied field's error projection. A resolver can correctly refuse the email while an error extension includes the stored value or another restricted property. The useful acceptance evidence includes the complete returned shape for the selected caller, not just the field's null value. This preserves the distinction between an allowed mutation, a denied return field, and a client-visible error that must itself respect the audience.

For the next schema change, add one row to the field policy matrix before adding the resolver. Then use the REST object authorization guide to compare the shared business rule across API styles, or the alert-to-claim guide when a tool flags a resolver without a complete path.