Field-Level Authorization with the @policy Directive
The Federation v2 @policy directive externalises authorization decisions: instead of encoding scope arithmetic in your schema, you tag fields and types with named policies and let an external engine — the Apollo Router’s authorization plugin, Open Policy Agent (OPA), or Cedar — decide whether the caller satisfies each one. This page covers how @policy works, how policy names act as selectors, how the router resolves them from JWT claims, and when to reach for it over @requiresScopes. It sits alongside the broader Directive Patterns for Cross-Service Authorization guide.
When to use this pattern
- Your access rules are richer than “has scope X” — they depend on relationships, attributes, tenancy, or rules that change without a schema redeploy.
- You already run, or want to run, a policy engine (OPA/Rego, Cedar, or a custom evaluator) and want GraphQL fields to reference those policies by name.
- You need authorization decisions auditable and managed outside the schema, while still enforced centrally at the router before subgraphs are called.
If your rules genuinely reduce to static scopes carried in a JWT, Implementing @authenticated and @requiresScopes Directives is simpler and needs no external engine.
Prerequisites
How @policy works
@policy carries a list of policy names rather than concrete permissions. Like @requiresScopes, the argument is a list of lists: the outer list is OR, the inner list is AND. The names are opaque to the schema — they are selectors that the router hands to an external evaluator. The evaluator returns the set of policies the current request satisfies, and the router authorizes a field only if its policy requirement is met by that set.
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.9"
import: ["@key", "@policy"]
)
type Query {
# Caller must satisfy the "viewer" policy to list accounts.
accounts: [Account!]! @policy(policies: [["viewer"]])
}
type Account @key(fields: "id") @policy(policies: [["account_member"]]) {
id: ID!
displayName: String!
# Needs (account_owner) OR (finance_admin) — two independent policy sets.
balanceCents: Int! @policy(policies: [["account_owner"], ["finance_admin"]])
# Needs BOTH account_owner AND pii_access on the same evaluation.
taxId: String! @policy(policies: [["account_owner", "pii_access"]])
}
The crucial difference from @requiresScopes: a scope like read:billing is a literal string the caller must possess, decided purely by the token. A policy like account_owner is a question the engine answers per request, often using the requested entity’s data and the caller’s identity together. That is what makes @policy suited to relationship- and attribute-based access control.
Field scope versus type scope
@policy is valid on FIELD_DEFINITION, OBJECT, INTERFACE, SCALAR, and ENUM. The scope you choose changes the blast radius:
- Type-level (
type Account @policy(...)) gates access to the entity as a whole. If the caller fails the type policy, every field requiring data from that type is pruned, including via entity references resolved from other subgraphs. Use this for “can this principal touch this kind of resource at all”. - Field-level (
balanceCents: Int! @policy(...)) gates a single column. Sibling fields remain readable. Use this for selectively masking sensitive attributes on an otherwise readable entity.
Requirements compose: a request for Account.balanceCents must satisfy the type policy (account_member) and the field policy (account_owner OR finance_admin). Stack them deliberately — an overly strict type policy silently makes field policies unreachable.
Configuring policy evaluation in the router
The authorization plugin gathers the distinct policy names a request references and hands them to your evaluation hook. The simplest production wiring uses a Rhai script or coprocessor that calls OPA/Cedar and writes the satisfied set back into the request context under the key the plugin reads.
# router.yaml
authentication:
router:
jwt:
jwks:
- url: https://idp.example.com/.well-known/jwks.json
authorization:
directives:
enabled: true
errors:
log: true
response: errors # filtering mode: drop unauthorized fields, attach error extensions
# A coprocessor that resolves which policies the caller satisfies for this request.
coprocessor:
url: http://localhost:4020/authorize
router:
request:
context: true # gives the coprocessor access to verified claims + required policies
The coprocessor receives the required policy names and the verified claims, asks the engine, and returns the satisfied subset. With OPA, that is one decision call per request:
// authorize-coprocessor.ts — bridges the router to an OPA decision endpoint.
import express from 'express';
const app = express();
app.use(express.json());
app.post('/authorize', async (req, res) => {
const claims = req.body.context?.entries?.['apollo::authentication::jwt_claims'] ?? {};
// The router exposes the policies it needs; ask OPA which are satisfied.
const required: string[] = req.body.context?.entries?.['apollo::authorization::required_policies'] ?? [];
const decision = await fetch('http://opa:8181/v1/data/graphql/authz', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ input: { claims, required } }),
}).then(r => r.json() as Promise<{ result: { satisfied: string[] } }>);
// Write the satisfied policies back; the authorization plugin filters fields against this.
res.json({
control: 'Continue',
context: {
entries: {
'apollo::authorization::policies': Object.fromEntries(
required.map(name => [name, decision.result.satisfied.includes(name)]),
),
},
},
});
});
app.listen(4020);
The matching Rego keeps the actual rules out of the schema and out of the router, where security teams can own them:
# graphql/authz.rego
package graphql.authz
satisfied[name] {
name := input.required[_]
name == "viewer"
input.claims.sub != ""
}
satisfied[name] {
name := input.required[_]
name == "account_owner"
input.claims.account_id == input.claims.sub_account # relationship rule
}
@policy versus @requiresScopes
| Aspect | @requiresScopes |
@policy |
|---|---|---|
| Argument | Concrete scope strings | Named policies (selectors) |
| Decision source | The JWT itself (does it carry the scope?) | External engine evaluates per request |
| Rule changes | Re-issue tokens / change scope mapping | Edit policy in OPA/Cedar, no redeploy |
| Data-dependent | No — purely token contents | Yes — can use entity/relationship data |
| Infra needed | None beyond JWT validation | A policy engine / coprocessor |
| Best for | Static, coarse permission gating | ABAC/ReBAC, dynamic, auditable rules |
Both share the AND/OR list-of-lists shape and both are enforced in the router’s filtering mode by default. They also compose together on the same field — @requiresScopes for a cheap token-level gate plus @policy for the data-dependent decision is a common layered pattern.
Verification steps
- Compose and confirm
@policyis recognised (it is stripped from the public API schema):
rover supergraph compose --config supergraph.yaml > supergraph.graphql
- Start the router with the coprocessor and OPA running. Query a policy-gated field as a caller who fails the rule:
query {
accounts { displayName balanceCents }
}
Expected — displayName resolves, balanceCents is null, with an error extension naming the unmet policy:
{
"data": { "accounts": [{ "displayName": "Acme Ltd", "balanceCents": null }] },
"errors": [
{
"message": "Unauthorized field or type",
"extensions": { "code": "UNAUTHORIZED_FIELD_OR_TYPE", "path": "accounts.@.balanceCents" }
}
]
}
- Re-run as a caller the engine says satisfies
account_owner; confirmbalanceCentsresolves and the billing subgraph is only then called. - Verify via router telemetry that exactly one policy decision call was made per request, not one per field — proof the router deduplicated the required policy names.
Common mistakes and gotchas
- Treating policy names as scopes. A
@policyname is a selector for an external decision, not a permission the token must literally contain. Putting raw scope strings in@policywithout an evaluator means nothing is ever satisfied and every gated field returnsnull. - Type policy starving field policies. An overly broad type-level
@policyprunes the whole entity, so field-level policies never get a chance to grant narrower access. Verify the type policy is satisfiable for the personas that need any field on it. - Evaluating per field instead of per request. Calling OPA/Cedar once per field reproduces the N+1 problem. Collect the distinct policy names for the whole operation and make a single decision call, as the coprocessor above does.
Frequently Asked Questions
When should I choose @policy over @requiresScopes?
Use @policy when decisions depend on data or relationships, must change without redeploying schemas or re-issuing tokens, or need to be owned by a security team in an external engine. Use @requiresScopes when the rule is a static permission the JWT already carries, as covered in Implementing @authenticated and @requiresScopes Directives.
Does @policy call my policy engine once per field?
No. The router collects the distinct policy names referenced across the whole operation and makes one evaluation call, then filters every field against the returned satisfied set. Your coprocessor or Rhai hook should be written to answer all required policies in a single decision.
Can I combine @policy with @requiresScopes on the same field?
Yes. Requirements compose with AND semantics, so a field annotated with both must satisfy the scope check and the policy decision. A common layered pattern uses @requiresScopes as a cheap token gate and @policy for the data-dependent rule.
Related
- Implementing @authenticated and @requiresScopes Directives — sibling guide for scope-based gating
- Directive Patterns for Cross-Service Authorization — parent guide
- Subgraph Implementation & Entity Resolution — section overview
- Implementing Entity Resolvers with @key Directives — how type-level policies interact with entity references
- Using @external and @requires for Field Resolution — resolving data needed for data-dependent policies