skip to content

Field-Level Authorization

A graph reaches the same object down many paths, so a check on the root field alone leaves it exposed through another edge. Interviewers ask because this is where a graph leaks another tenant's data.

part ofGraphQLoverview, primer and where to startread it →
on this pageshow

questions

5

Why is authorizing only the entry field of a GraphQL query not enough to protect the data behind it?

level: juniorimportance: must knowfreq 62%

answer

  1. One object, several ways in
  2. Routes are enumerable; graph edges are not
  3. The root field is one entrance of many
  4. Authorize the edge you are resolving
  5. Viewer plus parent plus field

basics

~20 s

A graph reaches the same object down many paths, so a check on the root field guards one entrance only. Every other schema field returning that object is another way in, and each needs its own check.

solid answer

~50 s

In a REST API each resource has a URL and one handler, so guarding the handler guards the data. GraphQL has one endpoint and a graph: a document starts at a root field and then walks edges, and **any field in the schema whose type is the protected type is another entrance**. In a freight-tracking schema, guarding `Query.shipment(id:)` does nothing for `carriers { shipments { rateAgreement } }`, which reaches the same shipments without touching the guarded root. Authorization is therefore a property of the *edge being resolved* — decided from the viewer, the parent object and the field — not of where the document happened to start. The specification defines no authorization at all: it covers parsing, validation and execution and leaves this to the server. So either every resolver returning non-public data checks, or the check is pushed into a layer all of them go through.

code

graphql · 11 lines
graphql
query TwoPathsToTheSameObject {
  guarded: shipment(id: "SHP-40718") {
    rateAgreement { negotiatedRateCents }
  }
  unguarded: carriers {
    shipments {
      id
      rateAgreement { negotiatedRateCents }
    }
  }
}

go deeper

for a junior

Be ready to say in one sentence why one endpoint plus a graph breaks per-route thinking, and to point at a second path to the same object in a schema you are shown.

for a middle

Explain the resolver tuple — viewer, parent object, field, arguments — and split static role checks from instance-level checks that need the loaded object before they can be decided.

for a senior

Show how you get coverage rather than discipline: a shared enforcement point every traversal passes through, and tests that assert denial on the paths nobody thought of.

for a principal

Own the position that per-resolver checks do not scale across many schema-contributing teams, and argue for a default-deny mechanism with auditable, enumerable exceptions.

## Why the shape of GraphQL changes the question Authorization in a request/response API is usually reasoned about per endpoint. A REST route such as `GET /shipments/{id}` has exactly one handler; put the check at the top of that handler and there is no other way to obtain a shipment. The route *is* the access point, and enumerating routes enumerates the attack surface. GraphQL removes that one-to-one mapping. There is a single endpoint, and what a caller can obtain is decided by the **type graph**, not by a route table. A document names a root field and then descends through fields, and every field that returns an object type is an edge into that type. If your schema declares `Shipment` in five places, there are five ways to arrive at a shipment, and a check written in one of them is a check the other four never run. ## A concrete freight-tracking example ```graphql type Query { shipment(id: ID!): Shipment carriers: [Carrier!]! } type Carrier { id: ID! legalName: String! shipments: [Shipment!]! } type Shipment { id: ID! status: ShipmentStatus! rateAgreement: RateAgreement } type RateAgreement { negotiatedRateCents: Int! fuelSurchargePct: Float! } ``` Suppose the team writes the check in the resolver for `Query.shipment` — it loads the shipment, compares its account against the viewer's account and refuses if they differ. That resolver is now safe. But this document never calls it: ```graphql query { carriers { shipments { id rateAgreement { negotiatedRateCents } } } } ``` `Carrier.shipments` produced the same objects by a different edge, and `Shipment.rateAgreement` handed over a competitor's negotiated rate. Nothing about the request was malformed; the schema promised those edges and execution delivered them. ## The unit that must be authorized Every resolver invocation carries four things: the **parent object** it is resolving on, the **field** being resolved, the **arguments**, and a per-request **context** that carries the viewer. Authorization is a decision over exactly that tuple. Two useful consequences follow. First, the decision can be *static* — does this viewer hold the role or scope that this field requires? — or *instance-level* — is this viewer a party to *this* shipment? Static rules can be attached to the field once. Instance-level rules need the parent object in hand, which means they can only be answered while the field is being resolved. Second, a check on arguments is not a check on results. `Query.shipments(carrierId: ID!)` verifying that the viewer belongs to `carrierId` looks correct, and stays correct only until someone adds a filter argument, a sort, or a nested edge that crosses carriers. Authorize what you are about to return, not what you were asked for. ## What the specification does and does not give you The GraphQL specification defines the type system, document validation and the execution algorithm. It defines no authorization phase, no viewer concept, no reserved directive for access control, and no rule that unauthorized fields disappear. Everything in this area is convention implemented by servers and teams. The one property execution does give you is that a failure while resolving one field is a *field error* — the rest of the response still executes and the response can carry both partial data and an error entry describing that field. (What happens to parents when the denied field is non-nullable is a separate topic in its own right.) ## Where the check can live Three placements are common, and real systems mix them: - **In each resolver.** Explicit and easy to read, and the one that scales worst: coverage depends on every author of every new field remembering. - **Declaratively on schema fields**, via a team-defined directive the server enforces. Makes the rule visible in the schema and lintable, but a static marker cannot answer instance-level questions on its own. - **In the data-loading layer**, so every traversal that fetches the object passes the same gate — coverage by construction rather than by discipline. ## Failure modes an interviewer listens for - Guarding only root fields, or only mutations, because "reads are harmless". - Treating a field's absence from a generated client as protection. Any caller can name any field the schema declares. - Treating a disabled introspection endpoint as authorization; it hides the map, not the data. - Accepting a tenant identifier from the client and authorizing against it rather than against the viewer's own identity. - Filtering in the UI. The response already left the server. ## What a good answer sounds like "Because the graph has more than one path to the same object. The check has to sit where the object is produced — on the edge, with the parent and the viewer in hand — and I would rather enforce it in the loading layer than trust every future resolver author to remember."

  • Does the GraphQL specification define where authorization happens?
    No. The specification covers the type system, document validation and the execution algorithm. It defines no authorization phase, no viewer, and no access-control directive, so every rule here is a server or team convention. What execution does give you is that a resolver failure is localized to its field, so a denial on one field does not abort the whole response.
  • Why can't an instance-level rule be enforced before execution starts, from the document alone?
    Because the document names fields, not records. "May this viewer read this shipment's rate?" needs the shipment in hand — its account, its carrier, its state — and none of that exists until a resolver has loaded it. Static rules that depend only on the viewer's roles or scopes can be decided from the document; anything that depends on the object cannot.
  • A field is missing from the client's generated types. Is it protected?
    No. Code generation reflects what one client chose to use; the executable schema still declares the field, and any caller that names it gets it. The same is true of a field kept out of documentation or hidden by disabling introspection — that removes the map, not the data. Only an enforced check on the field protects it.

Guarding the root field is locking the front door of a warehouse whose loading bay, side stairwell and roof hatch all open onto the same aisle.

saying these in an interview costs you the question

  • Says a check on the root field covers everything below it
  • Thinks the GraphQL specification defines an authorization phase
  • Believes only mutations need authorization checks
  • Treats a field missing from generated client types as protected
  • Authorizes the arguments requested instead of the data returned
  • Calls disabled introspection an access-control measure

context

open as a page

Why do teams push GraphQL field authorization down into the data-loading layer, and what does it cost?

level: seniorimportance: must knowfreq 54%

basics

~20 s

Every traversal that reaches an object goes through the loader, so a check there covers paths nobody enumerated, including fields added later. It costs a batch cache that must be viewer-scoped, denial becoming absence, and policy lookups needing their own batching.

open as a page

In a GraphQL schema, what can an authorization directive on a field decide, and what must a resolver still check?

level: middleimportance: should knowfreq 48%

basics

~20 s

A directive on a schema field carries a static rule — the role or scope the viewer must hold — which the server enforces uniformly. It cannot decide whether this viewer is party to this particular record; only a resolver holding the loaded object can.

open as a page

In a GraphQL schema many teams extend, how do you guarantee every new field is authorized before it ships?

level: principalimportance: should knowfreq 42%

basics

~20 s

Make the default deny rather than allow, so an unmarked field fails a build check instead of shipping open; require an explicit, reviewed exception list for genuinely public fields; and prove enforcement with denial tests and denial metrics rather than with schema review.

open as a page

How can a denied GraphQL field confirm that a record exists, and when does that matter?

level: seniorimportance: nice to knowfreq 26%

basics

~20 s

If a forbidden record produces an error entry at that field's path while a non-existent one produces a plain null, the difference answers a question the caller was never authorized to ask. It matters when identifiers are guessable or enumerable.

open as a page