Directive Patterns for Cross-Service Authorization
Cross-service authorisation in distributed GraphQL requires a standardised, declarative approach to security so that access rules travel with the schema rather than scattering across resolver code. As platform teams scale Subgraph Implementation & Entity Resolution across many domains, enforcing consistent access control becomes an architectural problem in its own right. This guide details the directive patterns that solve it — the Federation v2 native authorisation directives, the router and subgraph enforcement points they drive, and the performance trade-offs between them — and links out to the deeper guides on implementing @authenticated and @requiresScopes directives and field-level authorization with the @policy directive.
The Problem: Authorisation That Survives Composition
In a federated graph, a single client query fans out across multiple subgraphs that each own part of a type. If each subgraph hand-rolls its own authorisation in resolver code, three things go wrong: the rules drift apart between teams, the supergraph has no visibility into them, and the router cannot make any access decision before paying the cost of routing. Federation v2 answers this by promoting authorisation to first-class directives — @authenticated, @requiresScopes, and @policy — that the composition engine understands and the Apollo Router enforces centrally during query planning.
The router acts as the policy enforcement point (PEP). It evaluates directive constraints against request claims and prunes or rejects fields before dispatching subqueries, so an unauthorised field never reaches the subgraph that owns it. Subgraphs remain the place for fine-grained, data-dependent checks where business context lives. Getting the split right is the substance of this section.
Core Concepts Overview
This section breaks down into two focused guides:
- Implementing @authenticated and @requiresScopes directives — the coarse-grained tier: gating fields behind “is this request authenticated” and “does it carry these OAuth scopes”, enforced centrally by the router from JWT claims.
- Field-level authorization with the @policy directive — the externalised tier: delegating named policy decisions to an external evaluator (OPA, Cedar, or a custom store) so rules change without redeploying schema.
The page below establishes the shared model both guides build on: directive placement, claim propagation, enforcement layers, and the performance envelope.
Native Federation v2 Authorisation Directives
Import the directives you use in each subgraph’s @link. Composition merges these annotations into the supergraph, and the router enforces them at the planning stage.
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.9"
import: ["@key", "@authenticated", "@requiresScopes", "@policy"]
)
type Query {
publicCatalog: [Product!]! # open
myOrders: [Order!]! @authenticated # any logged-in user
}
type Invoice @key(fields: "id") {
id: ID!
amount: Float! @requiresScopes(scopes: [["finance:read"]])
lineItems: [LineItem!]! @policy(policies: [["tenant_owner"]])
}
Directive placement dictates granularity. A field-level directive intercepts resolution immediately before that field’s data is fetched; a directive on an object type applies to the whole entity. Align directive scopes with entity ownership boundaries so authorisation integrates cleanly with implementing entity resolvers with @key directives, preventing unauthorised entity stitching while preserving referential integrity across service boundaries.
Implementation Note: Never apply authorisation directives to the reference fields the router uses to stitch entities (id, __typename). Federation relies on lightweight reference resolution to assemble entities across subgraphs; blocking those fields prematurely breaks the query plan and surfaces as confusing partial-data errors rather than clean denials.
Architecture Diagram: Where Each Check Runs
The diagram traces a single authenticated query through the enforcement tiers, showing what the router decides before routing and what the subgraph decides with business context.
Reading left to right: the client presents a Bearer JWT; the router validates the signature, materialises the claims, and enforces @authenticated and @requiresScopes while building the query plan — pruning or rejecting any field the request is not entitled to. Only the surviving fields are dispatched, with claims forwarded to subgraphs as headers, where @policy checks run against business context. The pink arrow captures the central efficiency: a field gated by a coarse directive is never routed at all, so the owning subgraph spends nothing on a request it would have denied.
Context Propagation and Token Validation
Secure cross-service communication requires propagating authentication claims through the router. The standard workflow extracts the JWT at the edge, validates its signature, materialises the claims, and makes them available both to the router’s directive evaluation and to subgraphs via forwarded headers. Where authorisation depends on computed or remote data, treat permission metadata the way you treat any cross-subgraph dependency in using @external and @requires for field resolution: resolve the dependency before the protected field executes.
Router JWT authentication (router.yaml)
The router can validate JWTs natively from a JWKS endpoint, which is the foundation @authenticated and @requiresScopes build on:
# router.yaml
authentication:
router:
jwt:
jwks:
- url: https://auth.internal/.well-known/jwks.json
issuer: https://auth.internal/
authorization:
require_authentication: false # let directives decide per-field
headers:
all:
request:
- propagate:
named: authorization # forward to subgraphs for fine-grained checks
Deploying and tuning this router configuration is covered in Apollo Router configuration and deployment, which is the operational counterpart to the schema-side patterns here.
Subgraph directive resolver hook
For fine-grained checks a subgraph reads forwarded claims and applies them with a schema transformer:
import { mapSchema, getDirective, MapperKind, defaultFieldResolver } from '@graphql-tools/utils';
import { GraphQLError, GraphQLSchema } from 'graphql';
export const policyDirectiveTransformer = (schema: GraphQLSchema) =>
mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const directive = getDirective(schema, fieldConfig, 'policy')?.[0];
if (!directive) return fieldConfig;
const { resolve = defaultFieldResolver } = fieldConfig;
// policies is [[String]] — outer OR of inner AND groups.
const required: string[][] = directive.policies ?? [];
fieldConfig.resolve = async (parent, args, context, info) => {
const granted: string[] = context.user?.policies ?? [];
const ok = required.some(group => group.every(p => granted.includes(p)));
if (!ok) {
throw new GraphQLError('Forbidden: policy check failed', {
extensions: { code: 'FORBIDDEN' },
});
}
return resolve(parent, args, context, info);
};
return fieldConfig;
},
});
Directive & Config Reference
| Directive | Granularity | Enforced at | Evaluates | Notes |
|---|---|---|---|---|
@authenticated |
Field / object | Router (planning) | Presence of a valid token | Coarsest gate; cheapest to enforce |
@requiresScopes(scopes: [[String]]) |
Field / object | Router (planning) | OAuth scopes in claims | [[ ]] is OR-of-AND scope sets |
@policy(policies: [[String]]) |
Field / object | Router → external evaluator / subgraph | Named policy decisions | Externalise to OPA/Cedar; data-dependent |
@key (do not gate) |
Object | Router | Entity reference fields | Never apply auth here — breaks stitching |
| JWKS config | n/a | router.yaml |
Token signature | Foundation for all three directives |
Composition Pipeline Integration
Because the directives are part of the schema, they flow through the same checks as any other SDL change. Validate that authorisation annotations compose before publishing:
# Confirm directive usage composes against the published supergraph.
rover subgraph check "$APOLLO_GRAPH_REF" \
--name billing \
--schema ./services/billing/schema.graphql
rover supergraph compose --config supergraph.yaml --output supergraph.graphql
A common composition failure is importing a directive in one subgraph but referencing it in another that forgot the @link import — the check fails with an “unknown directive” error. Keep the @link import list in sync across services, ideally from a shared schema fragment.
Modelling Scope and Policy Sets
Both @requiresScopes and @policy take a doubly-nested list — [[String]] — and the nesting encodes boolean logic that teams routinely get backwards. The outer list is an OR; the inner lists are ANDs. So @requiresScopes(scopes: [["a", "b"], ["c"]]) reads as “(a AND b) OR c”: the request is authorised if it carries both a and b, or alternatively just c. Modelling this correctly matters because the wrong shape silently widens or narrows access. A common error is flattening two independent permissions into one inner group, turning an OR into an AND and locking out users who legitimately hold only one of them.
Design scope strings as stable, coarse capabilities — finance:read, orders:write, admin — rather than encoding tenant or resource identity into the string. Scopes answer “what kind of action may this principal perform”; they should not answer “on which specific record”, because that is data-dependent and belongs to @policy. Keeping the two concerns separate keeps the router’s planning-time checks cheap and deterministic, which in turn keeps them cache-friendly. When a scope set starts to encode row-level identity, that is the signal to move the rule down into a policy evaluated with subgraph context.
A practical convention is to maintain the canonical scope vocabulary in one place — a shared constants module imported by both the authorisation server that mints tokens and the subgraphs that annotate schema. Drift between the scopes a token can carry and the scopes a directive demands produces denials that look like authorisation bugs but are really vocabulary mismatches. Treat the scope list as a contract with the same rigour you apply to scalar names in custom scalars in federated GraphQL schemas: one source of truth, versioned, and validated in CI.
Denial Semantics and the User Experience of “No”
How a federated graph says “no” is part of its API contract. The cleanest behaviour for a field the caller is not entitled to is a typed GraphQLError with extensions.code: "FORBIDDEN", leaving the rest of the response intact where the schema allows. The router’s directive enforcement does this well at planning time, but subgraph-level @policy checks must take care not to throw on a non-nullable field whose parent cannot tolerate a null — otherwise a single denied field cascades through null-propagation and erases a large, otherwise-authorised slice of the response.
There is a deliberate design choice between hiding and denying. Returning FORBIDDEN confirms the field exists but is off-limits, which is appropriate for most internal platforms. For multi-tenant systems where the very existence of a record is sensitive, prefer to model the field as nullable and return null with no error, so an unauthorised caller cannot distinguish “you may not see this” from “this does not exist”. Decide this per field, document it, and keep it consistent within a type — mixing the two within one entity confuses clients and complicates their error handling. Whichever you choose, make denials observable on the server side so that a spike in FORBIDDEN responses surfaces in the telemetry described under Apollo Router configuration and deployment, where you can tell a genuine attack from a misconfigured client.
Performance & Scale Considerations
Where you enforce a directive directly shapes latency, cacheability, and resource use.
| Enforcement layer | Best use | Latency impact | Cache implications |
|---|---|---|---|
Router (@authenticated, @requiresScopes) |
Authentication, tenant isolation, static scope checks | Minimal — runs during planning, prunes work | Safe for shared caching if tenant/scope is part of the cache key |
Subgraph (@policy, resolver checks) |
Resource ownership, dynamic data-dependent RBAC | Adds resolver overhead; scales horizontally | Invalidates shared caches; needs per-user segmentation |
Routing coarse checks to the router pays off twice: it centralises validation and it prunes unauthorised fields before they cost a subgraph round-trip. Reserve subgraph enforcement for decisions that genuinely need business data. Because dynamic, user-scoped checks fragment shared caches, coordinate authorisation design with your caching layer — segment cache keys by scope, or restrict per-user directives to operations you do not cache.
Debugging Workflow for Latency Spikes:
- Enable router execution tracing (
APOLLO_ROUTER_LOG=debug). - Find the authentication and authorization spans in the trace.
- If subgraph latency tracks directive checks, confirm JWT validation is not repeating per field — cache parsed claims on the request context with a TTL matching token expiry.
Externalising Policy Decisions
Complex authorisation matrices belong outside SDL. Use @policy arguments as selectors against an external store rather than hardcoding role tables into the schema.
Anti-pattern: baking concrete role lists into SDL, which couples every policy change to a schema redeploy.
type Invoice @key(fields: "id") {
amount: Float! @policy(policies: [["finance_read"]])
lineItems: [LineItem!]! @policy(policies: [["tenant_owner"]])
}
The evaluator maps tenant_owner against a policy engine (Open Policy Agent, Cedar, or a custom rule store), so role updates ship without touching the graph. This externalised model is the full subject of field-level authorization with the @policy directive.
Failure Modes & Debugging
| Pitfall | Symptom | Resolution |
|---|---|---|
Gating a @key reference field |
Partial data / broken stitching, not a clean denial | Never apply auth directives to id / __typename; gate the sensitive fields instead |
| Unknown directive at composition | rover subgraph check fails with “unknown directive” |
Keep the @link import list identical across subgraphs |
| N+1 token validation | High subgraph CPU on batched queries | Cache parsed JWT claims per request context; do not re-parse in directive hooks |
| Hardcoded role matrices in SDL | Frequent schema redeploys for role changes | Externalise via @policy selectors against a policy engine |
| Header propagation loss | 401 on downstream subgraph calls |
Propagate authorization to subgraphs in router.yaml |
Debugging Checklist:
- Log
context.userin a test resolver to confirm claim propagation reached the subgraph. - Use Apollo Studio traces or router telemetry to confirm directive evaluation order and timing.
- Query a protected entity reference without the required scope and expect a clean
GraphQLError, not a silentnullor a partial response.
Decision Guide
Frequently Asked Questions
Should authorisation directives be enforced at the gateway or within individual subgraphs?
Enforce coarse, static checks — authentication and OAuth scopes via @authenticated and @requiresScopes — at the router so unauthorised fields are pruned before routing. Delegate fine-grained, data-dependent checks to subgraphs with @policy, where business context is available.
How do directive patterns impact GraphQL query caching?
Directives that evaluate dynamic user context inherently fragment shared caches. Segment cache keys by authorisation scope, or restrict per-user directives to operations you do not cache. Keep tenant and scope in the cache key when router-level checks are deterministic.
Can custom auth directives interfere with federation entity resolution?
Yes, if they block field resolution prematurely. Ensure entity reference fields and __resolveReference are never gated, so federation stitching stays intact and denials surface as clean GraphQLErrors rather than partial responses.
What is the difference between @requiresScopes and @policy?
@requiresScopes checks static OAuth scopes present in the validated token and is enforced by the router during planning. @policy delegates a named decision to an external evaluator, which is the right tool for data-dependent rules that change without a schema redeploy.
Do I need a coprocessor if I use the native directives?
Not for authentication or scope checks — the router validates JWTs and enforces @authenticated/@requiresScopes natively. A coprocessor or subgraph hook is only needed for custom logic the native directives cannot express.