Skip to content

PATTERN Cited by 1 source

Directive-based field authorization

Problem

A mixed-domain GraphQL graph — one where a single query may fetch public product data alongside confidential customer data — needs to enforce authentication and authenticity level at field granularity, not request granularity. Putting the authz check in a middleware before GraphQL parsing is too coarse (it would gate the whole query); scattering if (!authenticated) throw ... through every resolver is error-prone and invisible to schema readers.

Pattern

Declare a custom schema directive (Zalando: @isAuthenticated) on FIELD_DEFINITION. The directive's implementation wraps the resolver; before the resolver runs, it inspects the request context for the authenticated user and the user's authenticity level, and rejects the field with an authorization error if either is missing or insufficient.

scalar ACRValue @specifiedBy(url: "https://example.com/zalando-acr-value")

directive @isAuthenticated(
  acrValue: ACRValue
) on FIELD_DEFINITION

type Query {
  customer: Customer @isAuthenticated
}

type Mutation {
  updateCustomerInfo(
    email: String
    phoneNumber: String
  ): UpdateCustomerInfoResult @isAuthenticated(acrValue: HIGH)
}

Absent-argument ⇒ any-authenticated-user; present-argument ⇒ the session's ACR value must meet or exceed the declared level (Source: sources/2023-10-18-zalando-understanding-graphql-directives-practical-use-cases-at-zalando).

Why declarative beats imperative

  • Schema-as-policy-source. A reviewer reads the schema and sees immediately which fields require auth. An if (!ctx.user) scattered through N resolvers does not aggregate into a policy view.
  • Single resolver-wrapper. The directive's implementation is written once (a schema-directive wrapper via graphql-tools) and applied everywhere by the graph layer at compile time — no risk of forgetting the check in a new resolver.
  • Lint-friendly. A schema linter can check that every mutation touching customer data carries @isAuthenticated, turning the policy into a CI gate.
  • Client-invisible. Clients never write the directive; the policy can tighten without a client redeploy. Raising acrValue: MEDIUMHIGH for a sensitive mutation is a schema change only.

Step-up semantics

The acrValue argument turns field-level authz into step-up authentication. A low-risk Query.customer resolves as long as the user is authenticated at any level; a high-risk Mutation.updateCustomerInfo(email, phoneNumber) requires HIGH ACR, triggering a password / 2FA re-challenge if the session is at a lower level.

The ACR value semantic comes from OpenID Connect's authentication-context-class-reference taxonomy; Zalando declares its own values (scale of their choosing — LOW/MEDIUM/HIGH or OIDC-style URNs) and documents them via @specifiedBy on the ACRValue scalar.

Trade-offs

  • Resolver-wrap overhead. Every @isAuthenticated- annotated field pays the wrapper's cost per resolution. For a graph with deep authenticated subtrees, the cost is non-trivial; mitigation is to put the directive at the top of an authenticated subtree and let child fields inherit (requires convention, since GraphQL directives don't structurally inherit — but Zalando's convention is to gate at the entry point to the customer subgraph).
  • Off-schema context dependency. The directive's implementation needs the authenticated-user object wired into the GraphQL context at request time. That plumbing is independent of the directive and is a per-deployment concern.
  • Directive-only policy is a local maximum. Higher- assurance deployments combine the directive with cross- cutting enforcement: e.g. an outer edge proxy rejecting unauthenticated traffic before it hits the graph, so the directive serves as a defence-in-depth layer rather than the only gate.

Seen in

Last updated · 501 distilled / 1,218 read