Implementing @authenticated and @requiresScopes Directives
Apollo Federation v2 ships two built-in authorization directives, @authenticated and @requiresScopes, that let you declare access requirements in subgraph SDL and have the Apollo Router enforce them centrally before any subgraph is ever called. This page shows how to import them, annotate your schema, drive scopes from JWT claims, and reason about composition and fallback behaviour — a focused complement to the broader Directive Patterns for Cross-Service Authorization guide.
When to use this pattern
- You want declarative, schema-driven access control enforced at the router rather than hand-written directive resolvers in every subgraph.
- Your access rules are coarse-to-medium grained: “must be logged in” (
@authenticated) or “must hold scope X” (@requiresScopes), derived from verified JWT claims. - You are running the Apollo Router (not the legacy
@apollo/gateway), since enforcement of these built-ins lives in the router’s authorization plugin.
If your checks depend on the actual data being returned (resource ownership, row-level rules), prefer externalised policy evaluation via Field-Level Authorization with the @policy Directive instead.
Prerequisites
Importing the built-in directives
Unlike a custom directive @auth, you do not define @authenticated or @requiresScopes yourself — they are part of the federation spec. You import them through the same @link you already use for @key. The router strips them out of the public API schema during composition and turns them into enforcement metadata in the supergraph.
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.9"
import: ["@key", "@authenticated", "@requiresScopes"]
)
type Query {
# Any authenticated principal may read the catalogue.
products: [Product!]! @authenticated
# Only callers whose JWT carries BOTH read:orders AND read:billing.
invoices: [Invoice!]! @requiresScopes(scopes: [["read:orders", "read:billing"]])
}
type Product @key(fields: "id") {
id: ID!
name: String!
# Public field — no directive, resolvable by anonymous reference resolution.
priceCents: Int!
# Restricted column: requires the inventory:read scope.
stockOnHand: Int! @requiresScopes(scopes: [["inventory:read"]])
}
type Invoice @key(fields: "id") {
id: ID!
amountCents: Int!
# Type-level @authenticated could also be applied to the whole Invoice.
pdfUrl: String! @requiresScopes(scopes: [["read:billing"]])
}
The scopes argument is a list of lists, and the nesting is meaningful: the outer list is OR, the inner list is AND. scopes: [["read:orders", "read:billing"]] means “needs read:orders AND read:billing”. scopes: [["admin"], ["read:orders", "read:billing"]] means “either holds admin, OR holds both read:orders and read:billing”. Model alternative permission sets by adding outer entries; model compound requirements by adding inner entries.
Both directives are valid on FIELD_DEFINITION, OBJECT, INTERFACE, SCALAR, and ENUM. Applying @authenticated to an object type requires authentication for every field that requires fetching from that subgraph; applying it to a single field narrows the requirement.
How the router enforces them
Enforcement is opt-in. The directives compose into the supergraph regardless, but the router only acts on them when its authorization plugin is enabled. The flow looks like this:
The router does not throw a blanket 401 the moment a protected field is requested. By default it operates in filtering mode: fields the caller lacks scopes for are pruned from the query plan, the subgraph for them is never called, and the response carries null for those fields plus a structured error under extensions. This keeps partially authorized queries useful — a caller without inventory:read still receives name and priceCents for each Product, just not stockOnHand.
Wiring up router.yaml
Two plugins cooperate. The authentication plugin validates the JWT and populates the request context with claims; the authorization plugin reads the composed directive requirements and enforces them. Scopes are pulled from a claim path you configure.
# router.yaml
authentication:
router:
jwt:
jwks:
# The router fetches and caches signing keys from your IdP.
- url: https://idp.example.com/.well-known/jwks.json
header_name: authorization
header_value_prefix: "Bearer "
authorization:
require_authentication: false # let unauthenticated queries through; @authenticated fields still get filtered
directives:
enabled: true
# Where to read scopes from inside the verified JWT claims.
# A space-delimited "scope" claim (OAuth2 style) is parsed into an array automatically.
errors:
log: true
response: errors # emit an error extension per dropped field rather than failing the whole request
# Forward identity to subgraphs that still need it for fine-grained checks.
headers:
all:
request:
- propagate:
named: authorization
A few configuration choices carry real consequences:
require_authentication: truerejects any unauthenticated request outright with a 401, regardless of which fields it touches. Leave itfalseif your graph mixes public and protected fields.- The standard OAuth2
scopeclaim (a single space-delimited string) is recognised and split into individual scopes. If your IdP emits scopes under a different claim or as a JSON array, configure the claim mapping in your JWT setup so the authorization plugin sees an array of scope strings. response: errorskeeps filtering mode; switching the policy so missing scopes fail the operation is a deliberate, stricter posture you opt into.
Propagating claims to subgraphs
The router-level directives answer “is this caller allowed to see this field at all”. Subgraphs frequently still need the principal’s identity for resource-scoped logic and audit logging. Propagate the verified token (or a derived claims header) downstream — the headers block above forwards the Authorization header. The subgraph then reads it during reference resolution:
// subgraph context.ts
import jwt from 'jsonwebtoken';
export interface SubgraphContext {
userId?: string;
scopes: string[];
}
export function buildContext({ req }: { req: { headers: Record<string, string | undefined> } }): SubgraphContext {
const raw = req.headers['authorization']?.replace('Bearer ', '');
if (!raw) return { scopes: [] };
// The router already verified the signature; decode without re-verifying to avoid
// duplicating JWKS round-trips on every subgraph call.
const claims = jwt.decode(raw) as { sub?: string; scope?: string } | null;
return {
userId: claims?.sub,
scopes: claims?.scope?.split(' ') ?? [],
};
}
Decoding (not re-verifying) downstream is the common production choice: the router is the trust boundary that verifies signatures via JWKS, so re-validating in every subgraph wastes a network hop per request. Only re-verify in subgraphs if they are reachable on a network where the router is not the sole ingress.
Composition behaviour
When you compose, the directives travel into the supergraph but are removed from the client-facing API schema — clients never see @requiresScopes. Composition is additive: if stockOnHand carries @requiresScopes(scopes: [["inventory:read"]]) in the products subgraph and a @shareable duplicate exists elsewhere without the directive, the field is still protected wherever it resolves, but inconsistent annotations on the same shared field are a common source of confusion. Keep auth directives on the owning subgraph for a field and avoid annotating @shareable copies divergently.
# Compose locally and confirm the auth directives are recognised.
rover supergraph compose --config supergraph.yaml > supergraph.graphql
# Catch breaking auth changes against the registry before publishing.
rover subgraph check products \
--schema ./services/products/schema.graphql \
--name products
Verification steps
- Compose and start the router against the supergraph. Confirm startup logs show the authorization plugin enabled.
- Issue an unauthenticated query for a public field and an
@authenticatedfield together:
query {
products { name stockOnHand }
}
Expected: name returns, stockOnHand is null, and the response includes an authorization error extension naming the missing scope:
{
"data": { "products": [{ "name": "Widget", "stockOnHand": null }] },
"errors": [
{
"message": "Unauthorized field or type",
"extensions": { "code": "UNAUTHORIZED_FIELD_OR_TYPE", "path": "products.@.stockOnHand" }
}
]
}
- Re-issue with a JWT carrying
scope: "inventory:read"and confirmstockOnHandnow resolves. - Inspect router telemetry to verify the products subgraph was not called for
stockOnHandin the unauthorized case — proof the field was pruned from the query plan, not nulled after fetching.
Common mistakes and gotchas
- Forgetting to import the directives. Without
@authenticated/@requiresScopesin the@linkimport list, composition treats them as unknown directives and either errors or silently ignores them. The field then resolves with no protection. - Expecting a hard 401 by default. Filtering mode returns partial data with error extensions. If you need request-level rejection, set
require_authentication: trueor change the directive error policy — do not assume the absence of a 401 means the field was exposed. - Confusing AND/OR nesting.
scopes: [["a", "b"]]is AND;scopes: [["a"], ["b"]]is OR. Swapping them either locks out legitimate callers or grants access too broadly.
Frequently Asked Questions
Do @authenticated and @requiresScopes require the Apollo Router, or do they work with @apollo/gateway?
They are enforced by the Apollo Router’s authorization plugin. The legacy @apollo/gateway does not enforce these built-ins, so on a gateway deployment you would fall back to directive resolvers in subgraphs as described in Directive Patterns for Cross-Service Authorization.
Where does the router get scopes from in the JWT?
From the verified claims after the authentication plugin validates the token against JWKS. The OAuth2 scope claim (a space-delimited string) is split into an array automatically; non-standard claims need a mapping so the authorization plugin receives an array of scope strings.
What happens to a protected field when the caller lacks the scope?
By default the router prunes that field from the query plan, never calls its subgraph, returns null for it, and attaches a UNAUTHORIZED_FIELD_OR_TYPE error extension. Sibling fields the caller is authorized for still resolve normally.
Related
- Field-Level Authorization with the @policy Directive — sibling guide for externalized, data-dependent policy checks
- Directive Patterns for Cross-Service Authorization — parent guide
- Subgraph Implementation & Entity Resolution — section overview
- Implementing Entity Resolvers with @key Directives — how reference resolution interacts with protected fields
- Using @external and @requires for Field Resolution — resolving dependent fields before enforcement