skip to content

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%

answer

  1. Two halves of one decision
  2. The annotation is data until something reads it
  3. Roles and scopes need no record
  4. Row-level questions need the loaded object
  5. Declared rules make coverage countable

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.

solid answer

~50 s

No authorization directive is defined by the GraphQL specification. The specification says how a directive is declared and where it may be applied; what one *does* is the server's business, and access-control directives are a team convention. Applied to a field, such a directive is good at **static** rules: the viewer must hold a role, a scope or a claim, decidable from the request alone. Its virtues are that the rule is visible in the schema text, so a check can list unguarded fields, and that enforcement is uniform because the server applies it rather than each author. Its limit is instance-level policy: `@requiresScope(scope: "rates:read")` on `Shipment.rateAgreement` cannot answer whether this viewer is a party to *this* shipment, because that needs the loaded object. Most schemas end up with both — a declared static rule, plus an object-level check where the data is produced.

code

graphql · 12 lines
graphql
directive @requiresScope(scope: String!) on FIELD_DEFINITION | OBJECT

type Shipment {
  id: ID!
  status: ShipmentStatus!
  rateAgreement: RateAgreement @requiresScope(scope: "rates:read")
}

type RateAgreement @requiresScope(scope: "rates:read") {
  negotiatedRateCents: Int!
  fuelSurchargePct: Float!
}

go deeper

for a junior

Know that an access-control annotation in a schema is a team convention the server enforces, not something GraphQL performs by itself, and that it usually encodes a required role or scope.

for a middle

Be able to split static from instance-level policy on the spot, and explain why a scope on a field does not stop one tenant reading another tenant's record.

for a senior

Show how declared rules buy you auditability — a build-time scan for unannotated sensitive fields — and how you prove enforcement with denial tests rather than annotation-presence tests.

for a principal

Own the placement policy for a schema many teams extend: what must be declared, what may be enforced in code, and how the default for a brand-new field is decided.

## What a directive is, and what it is not A directive is an annotation attached to a location in a document or a schema. The specification defines the syntax for declaring one and the set of locations it may appear on, and it defines the behaviour of a handful of built-in ones. For anything a team declares itself, **the specification defines no behaviour at all** — the server chooses what to do when it sees the annotation. So an access-control directive is a convention, not a feature of GraphQL, and two servers given the same annotated schema may do entirely different things with it, or nothing. That matters in an interview because candidates often describe such a directive as though the language enforces it. It does not. A schema is data; something in the server has to read the annotation and wrap or gate the field's resolution. ## The static half of the decision Authorization decisions split cleanly in two. The **static** half depends only on the caller: does this viewer hold the `dispatcher` role, the `rates:read` scope, the internal-employee claim? That question can be answered from the request context without touching any data, which is exactly why it can be attached to the field declaration once and enforced everywhere the field is selected. ```graphql directive @requiresScope(scope: String!) on FIELD_DEFINITION | OBJECT type Shipment { id: ID! status: ShipmentStatus! rateAgreement: RateAgreement @requiresScope(scope: "rates:read") driverPhone: String @requiresScope(scope: "pii:read") } ``` The payoff is not that this is less code than an `if`. It is that the rule now lives in an artefact you can *scan*. A four-person platform team maintaining a schema that thirty-one product teams extend cannot read every resolver, but it can run a check over the schema and list every field of a sensitive type carrying no annotation. Declarative rules turn an authorization audit into a query over the schema. ## The instance half, which a directive cannot reach The other half depends on the object: is this viewer's account the shipper on *this* consignment, is this carrier the one that actually hauled *this* leg, has the customer's access to this shipment already been revoked? A static annotation on the field declaration has none of that in hand. It knows the viewer and the field; it does not know which shipment is about to be returned, and a scope check will happily let a dispatcher with `rates:read` read the negotiated rate of a shipment belonging to a competitor. So the honest description is a two-layer model: the directive answers *may this kind of caller ever read this kind of field*, and something closer to the data answers *may this caller read this row*. A candidate who claims the directive alone is sufficient has not thought about multi-tenancy. ## Where a directive-based scheme leaks Three failure modes recur. **Placement drift.** The annotation is attached where it was declared. Annotate `Shipment.rateAgreement`, and a new field `Leg.rateAgreement` added by another team six months later is unguarded. Annotating the *type* rather than every field that returns it is stronger, if the server supports applying the rule at that location. **Silent no-ops.** Because behaviour is server-defined, an annotation the server does not wire up is decoration. This is genuinely dangerous: the schema reads as though the field is protected, review passes, and nothing enforces it. The mitigation is a test that asserts a denial for each declared rule — not a test that the annotation is present. **Composition with arguments.** A field-level rule says nothing about the arguments the field was given. `shipments(accountId: ID!)` gated by a scope is still a cross-tenant read if the resolver trusts `accountId`. ## Choosing between the two placements Use a declared rule when the policy is coarse, stable and worth making visible: PII fields, financial fields, internal-only fields. Use a resolver or data-layer check when the policy depends on the record. Prefer to keep the *static* rule declared even when an instance check also exists, because the declaration is what makes coverage measurable — a field with an instance check buried in code looks identical, from outside, to a field with no check at all. One more practical note: whatever the mechanism, decide what a denial *looks like* — an error at the field's path, a null, an omitted list entry — and make it consistent across both layers, because a schema where the declarative layer errors and the data layer silently filters gives two different signals for the same policy.

  • Does the GraphQL specification give any meaning to a custom directive like this one?
    It gives it syntax, not behaviour. The specification defines how a directive is declared, which locations it may be applied to, and the semantics of the built-in ones. A team-defined access-control annotation means whatever the server's wiring makes it mean, and means nothing at all if nobody wired it up — which is why a test asserting a real denial matters more than a test asserting the annotation exists.
  • Is it better to annotate the field or the type it returns?
    Annotating the type, where the server supports that location, is the more durable of the two: it covers every field that returns the type, including ones added later by teams that never read your policy. Field-level annotation is more precise but drifts, because protection attaches to the declaration you remembered rather than to the data.
  • How would you find fields in a large schema that have no declared rule?
    Walk the schema — from a build-time parse of the SDL rather than a live introspection call — and list every field whose type is on a sensitive list and carries no annotation, then require an explicit, reviewed opt-out entry for anything genuinely public. That turns coverage from a review question into a build check that fails on the next unannotated field.

The directive is the sign on the door saying badge-holders only; it cannot tell whether this badge-holder is the person whose file is inside.

saying these in an interview costs you the question

  • Claims the specification defines an authorization directive
  • Says a scope check on a field prevents cross-tenant reads
  • Assumes an annotation is enforced without server wiring
  • Tests that the directive is present rather than that access is denied
  • Forgets that a new field returning the same type is unguarded
  • Trusts an account identifier passed as a field argument

context