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:

JWT claim extraction and scope enforcement in the Apollo Router A client request carries a JWT. The router's JWT plugin verifies it against JWKS and writes claims to the request context. The authorization plugin reads required scopes from supergraph directives, compares them to the claim scopes, removes unauthorized fields, then dispatches the filtered query plan to subgraphs. Client Bearer JWT Apollo Router 1. JWT plugin verify via JWKS 2. Claims to context scopes[] 3. Authorization match directive scopes vs claims drop fields Products subgraph Billing subgraph Filtered response

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: true rejects any unauthenticated request outright with a 401, regardless of which fields it touches. Leave it false if your graph mixes public and protected fields.
  • The standard OAuth2 scope claim (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: errors keeps 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

  1. Compose and start the router against the supergraph. Confirm startup logs show the authorization plugin enabled.
  2. Issue an unauthenticated query for a public field and an @authenticated field 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" }
    }
  ]
}
  1. Re-issue with a JWT carrying scope: "inventory:read" and confirm stockOnHand now resolves.
  2. Inspect router telemetry to verify the products subgraph was not called for stockOnHand in 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/@requiresScopes in the @link import 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: true or 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.