Docs
Launch GraphOS Studio

Authorization in the Apollo Router

Strengthen service security with a centralized governance layer


This feature is only available with a GraphOS Dedicated or Enterprise plan.
To compare GraphOS feature support across all plan types, see the pricing page.

APIs provide access to business-critical data. Unrestricted access can result in data breaches, monetary losses, or potential denial of service. Even for internal services, checks can be essential to limit data to authorized parties.

Services may have their own access controls, but enforcing authorization in the Apollo Router is valuable for a few reasons:

  • Optimal query execution: Validating authorization before processing requests enables the early termination of unauthorized requests. Stopping unauthorized requests at the edge of your reduces the load on your services and enhances performance.

    ❌ Subquery
    ❌ Subquery
    ⚠️Unauthorized
    request
    Apollo Router
    Users
    API
    Posts
    API
    Client

    • If every in a particular sub requires authorization, the 's query planner can eliminate entire subgraph requests for unauthorized requests. For example, a request may have permission to view a particular user's posts on a social media platform but not have permission to view any of that user's personally identifiable information (PII). Check out How it works to learn more.

    ✅ Authorized
    subquery
    ❌ Unauthorized
    subquery
    ⚠️ Partially authorized
    request
    Apollo Router
    Users
    API
    Posts
    API
    Client

    • Also, query deduplication groups requested based on their required authorization. Entire groups can be eliminated from the if they don't have the correct authorization.
  • Declarative access rules: You define access controls at the level, and composes them across your services. These rules create -native governance without the need for an extra orchestration layer.

  • Principled architecture: Through , the centralizes authorization logic while allowing for auditing at the service level. This centralized authorization is an initial checkpoint that other service layers can reinforce.


    🔐 Router layer                                                   
    🔐 Service layer
    Subquery
    Subquery
    Request
    Apollo Router
    Users
    API
    Posts
    API
    Client

How access control works

The provides access controls via authorization directives that define access to specific and types across your :

For example, imagine you're building a social media platform that includes a Users . You can use the @requiresScopes to declare that viewing other users' information requires the read:user scope:

type Query {
users: [User!]! @requiresScopes(scopes: [["read:users"]])
}

You can use the @authenticated to declare that users must be logged in to update their own information:

type Mutation {
updateUser(input: UpdateUserInput!): User! @authenticated
}

You can define both stogether or separatelyat the level to fine-tune your access controls. When are declared both on a field and the field's type, they will all be tried, and the field will be removed if any of them does not authorize it. composes restrictions into the so that each 's restrictions are respected. The then enforces these on all incoming requests.

Prerequisites

NOTE

Only the supports authorization s@apollo/gateway does not. Check out the migration guide if you'd like to use them.

Before using the authorization in your , you must:

Configure request claims

Claims are the individual details of a request's authentication and scope. They might include details like the ID of the user making the request and any authorization scopesfor example, read:profiles assigned to that user. The authorization use a request's claims to evaluate which and types are authorized.

To provide the with the claims it needs, you must either configure JSON Web Token (JWT) authentication or add an external coprocessor that adds claims to a request's context. In some cases (explained below), you may require both.

  • JWT authentication configuration: If you configure JWT authentication, the automatically adds a JWT token's claims to the request's context at the apollo_authentication::JWT::claims key.
  • Adding claims via coprocessor: If you can't use JWT authentication, you can add claims with a coprocessor. Coprocessors let you hook into the 's request-handling lifecycle with custom code.
  • Augmenting JWT claims via coprocessor: Your authorization policies may require information beyond what your JSON web tokens provide. For example, a token's claims may include user IDs, which you then use to look up user roles. For situations like this, you can augment the claims from your JSON web tokens with coprocessors.

Authorization directives

Authorization are turned on by default. To disable them, include the following in your 's YAML config file:

router.yaml
authorization:
directives:
enabled: false

@requiresScopes
Since 1.29.1

The @requiresScopes marks and types as restricted based on required scopes. The directive includes a scopes with an array of the required scopes to declare which scopes are required:

@requiresScopes(scopes: [["scope1", "scope2", "scope3"]])

💡 TIP

Use @requiresScopes when access to a or type depends only on claims associated with a claims object or access token.

If your authorization validation logic or data are more complexsuch as checking specific values in headers or looking up data from other sources such as databasesand aren't solely based on a claims object or access token, use @policy instead.

Depending on the scopes present on the request, the filters out unauthorized and types.

You can use Boolean logic to define the required scopes. See Combining required scopes for details.

The validates the required scopes by loading the claims object at the apollo_authentication::JWT::claims key in a request's context. The claims object's scope key's value should be a space-separated string of scopes in the format defined by the OAuth2 RFC for access token scopes.

claims = context["apollo_authentication::JWT::claims"]
claims["scope"] = "scope1 scope2 scope3"

Usage

To use the @requiresScopes in a , you can import it from the @link directive like so:

extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.5",
import: [..., "@requiresScopes"])

It is defined as follows:

scalar federation__Scope
directive @requiresScopes(scopes: [[federation__Scope!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM

Combining required scopes with AND/OR logic

A request must include all elements in the inner-level scopes array to resolve the associated or type. In other words, the authorization validation uses AND logic between the elements in the inner-level scopes array.

@requiresScopes(scopes: [["scope1", "scope2", "scope3"]])

For the preceding example, a request would need scope1 AND scope2 AND scope3 to be authorized.

You can use nested arrays to introduce OR logic:

@requiresScopes(scopes: [["scope1"], ["scope2"], ["scope3"]])

For the preceding example, a request would need scope1 OR scope2 OR scope3 to be authorized.

You can nest arrays and elements as needed to achieve your desired logic. For example:

@requiresScopes(scopes: [["scope1", "scope2"], ["scope3"]])

This syntax requires requests to have either (scope1 AND scope2) OR just scope3 to be authorized.

Example @requiresScopes use case

Imagine the social media platform you're building lets users view other users' information only if they have the required permissions. Your schema may look like this:

type Query {
user(id: ID!): User @requiresScopes(scopes: [["read:others"]])
users: [User!]! @requiresScopes(scopes: [["read:others"]])
post(id: ID!): Post
}
type User {
id: ID!
username: String
email: String @requiresScopes(scopes: [["read:email"]])
profileImage: String
posts: [Post!]!
}
type Post {
id: ID!
author: User!
title: String!
content: String!
}

Depending on a request's attached scopes, the executes the following differently. If the request includes only the read:others scope, then the executes the following filtered :

Raw query to router
query {
users {
username
profileImage
email
}
}
Scopes: 'read:others'
query {
users {
username
profileImage
}
}

The response would include an error at the /users/@/email path since that requires the read:emails scope. The can execute the entire successfully if the request includes the read:others read:emails scope set.

@authenticated
Since 1.29.1

The @authenticated marks specific and types as requiring authentication. It works by checking for the apollo_authentication::JWT::claims key in a request's context, that is added either by the JWT authentication plugin, when the request contains a valid JWT, or by an authentication coprocessor. If the key exists, it means the request is authenticated, and the executes the in its entirety. If the request is unauthenticated, the router removes @authenticated before planning the and only executes the parts of the query that don't require authentication.

Usage

To use the @authenticated in a , you can import it from the @link directive like so:

extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.5",
import: [..., "@authenticated"])

It is defined as follows:

directive @authenticated on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM

Example @authenticated use case

Diving deeper into the social media example: let's say unauthenticated users can view a post's title, author, and content. However, you only want authenticated users to see the number of views a post has received. You also need to be able to for an authenticated user's information.

The relevant part of your schema may look like this:

type Query {
me: User @authenticated
post(id: ID!): Post
}
type User {
id: ID!
username: String
email: String @requiresScopes(scopes: [["read:email"]])
posts: [Post!]!
}
type Post {
id: ID!
author: User!
title: String!
content: String!
views: Int @authenticated
}

Consider the following :

Sample query
query {
me {
username
}
post(id: "1234") {
title
views
}
}

The would execute the entire for an authenticated request. For an unauthenticated request, the router would remove the @authenticated and execute the filtered .

Query executed for an authenticated request
query {
me {
username
}
post(id: "1234") {
title
views
}
}
Query executed for an unauthenticated request
query {
post(id: "1234") {
title
}
}

For an unauthenticated request, the doesn't attempt to resolve the top-level me , nor the views for the post with id: "1234". The response retains the initial request's shape but returns null for unauthorized and applies the standard GraphQL null propagation rules.

Unauthenticated request response
{
"data": {
"me": null,
"post": {
"title": "Securing supergraphs",
}
},
"errors": [
{
"message": "Unauthorized field or type",
"path": [
"me"
],
"extensions": {
"code": "UNAUTHORIZED_FIELD_OR_TYPE"
}
},
{
"message": "Unauthorized field or type",
"path": [
"post",
"views"
],
"extensions": {
"code": "UNAUTHORIZED_FIELD_OR_TYPE"
}
}
]
}

If every requested requires authentication and a request is unauthenticated, the generates an error indicating that the is unauthorized.

@policy
Since 1.35.0

The @policy marks and types as restricted based on authorization policies evaluated in a Rhai script or coprocessor. This enables custom authorization validation beyond authentication and scopes. It is useful when we need more complex policy evaluation than verifying the presence of a claim value in a list (example: checking specific values in headers).

💡 TIP

If access to a or type is restricted solely by the claims associated with a claims object or access token, consider using @requiresScopes instead.

The @policy includes a policies that defines an array of the required policies that are a list of strings with no formatting constraints. In general you can use the strings as arguments for any format you like. The following example shows a policy that might require the support role:

@policy(policies: [["roles:support"]])

Using the @policy requires a Supergraph plugin to evaluate the authorization policies. This is useful to bridge authorization with an existing authorization stack or link policy execution with lookups in a database.

An overview of how @policy is processed through the 's request lifecycle:

  • At the RouterService level, the extracts the list of policies relevant to a request from the schema and then stores them in the request's context in apollo_authorization::policies::required as a map policy -> null|true|false.

  • At the SupergraphService level, you must provide a Rhai script or coprocessor to evaluate the map. If the policy is validated, the script or coprocessor should set its value to true or otherwise set it to false. If the value is left to null, it will be treated as false by the . Afterward, the router filters the requests' types and to only those where the policy is true.

  • If no of a passes its authorization policies, the stops further processing of the query and precludes unauthorized subgraph requests. This efficiency gain is a key benefit of the @policy and other authorization .

Usage

To use the @policy in a , you can import it from the @link directive like so:

extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.6",
import: [..., "@policy"])

The @policy is defined as follows:

scalar federation__Policy
directive @policy(policies: [[federation__Policy!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM

Using the @policy requires a Supergraph plugin to evaluate the authorization policies. You can do this with a Rhai script or coprocessor. Refer to the following example use case for more information. (Although a native plugin can also evaluate authorization policies, we don't recommend using it.)

Combining policies with AND/OR logic

Authorization validation uses AND logic between the elements in the inner-level policies array, where a request must include all elements in the inner-level policies array to resolve the associated or type. For the following example, a request would need policy1 AND policy2 AND policy3 to be authorized:

@policy(policies: [["policy1", "policy2", "policy3"]])

Alternatively, to introduce OR logic you can use nested arrays. For the following example, a request would need policy1 OR policy2 OR policy3 to be authorized:

@policy(policies: [["policy1"], ["policy2"], ["policy3"]])

You can nest arrays and elements as needed to achieve your desired logic. For the following example, its syntax requires requests to have either (policy1 AND policy2) OR just policy3 to be authorized:

@policy(policies: [["policy1", "policy2"], ["policy3"]])

Example @policy use case

Usage with a coprocessor

Diving even deeper into the social media example: suppose you want only a user to have access to their own profile and credit card information. Of the available authorization , you use @policy instead of @requiresScopes because the validation logic relies on more than the scopes of an access token.

You can add the authorization policies read_profile and read_credit_card. The relevant part of your schema may look like this:

type Query {
me: User @authenticated @policy(policies: [["read_profile"]])
post(id: ID!): Post
}
type User {
id: ID!
username: String
email: String @requiresScopes(scopes: [["read:email"]])
posts: [Post!]!
credit_card: String @policy(policies: [["read_credit_card"]])
}
type Post {
id: ID!
author: User!
title: String!
content: String!
views: Int @authenticated
}

You can use a coprocessor called at the request stage to receive and execute the list of policies.

If you configure your like this:

router.yaml
coprocessor:
url: http://127.0.0.1:8081
supergraph:
request:
context: true

A coprocessor can then receive a request with this format:

{
"version": 1,
"stage": "SupergraphRequest",
"control": "continue",
"id": "d0a8245df0efe8aa38a80dba1147fb2e",
"context": {
"entries": {
"apollo_authentication::JWT::claims": {
"exp": 10000000000,
"sub": "457f6bb6-789c-4e8b-8560-f3943a09e72a"
},
"apollo_authorization::policies::required": {
"read_profile": null,
"read_credit_card": null
}
}
},
"method": "POST"
}

A user can read their own profile, so read_profile will succeed. But only the billing system should be able to see the credit card, so read_credit_card will fail. The coprocessor will then return:

{
"version": 1,
"stage": "SupergraphRequest",
"control": "continue",
"id": "d0a8245df0efe8aa38a80dba1147fb2e",
"context": {
"entries": {
"apollo_authentication::JWT::claims": {
"exp": 10000000000,
"sub": "457f6bb6-789c-4e8b-8560-f3943a09e72a"
},
"apollo_authorization::policies::required": {
"read_profile": true,
"read_credit_card": false
}
}
}
}
Usage with a Rhai script

For another example, suppose that you want to restrict access for posts to a support user. Given that the policies is a string, you can set it as a "<key>:<value>" format that a Rhai script can parse and evaluate.

The relevant part of your schema may look like this:

type Query {
me: User @policy(policies: [["kind:user"]])
}
type User {
id: ID!
username: String @policy(policies: [["roles:support"]])
}

You can then use the following Rhai script to parse and evaluate the policies string:

fn supergraph_service(service) {
let request_callback = |request| {
let claims = request.context["apollo_authentication::JWT::claims"];
let policies = request.context["apollo_authorization::policies::required"];
if policies != () {
for key in policies.keys() {
let array = key.split(":");
if array.len == 2 {
switch array[0] {
"kind" => {
policies[key] = claims[`kind`] == array[1];
}
"roles" => {
policies[key] = claims[`roles`].contains(array[1]);
}
_ => {}
}
}
}
}
request.context["apollo_authorization::policies::required"] = policies;
};
service.map_request(request_callback);
}

Composition and federation

's strategy for authorization is intentionally accumulative. When you define authorization directives on and types in , GraphOS composes them into the . In other words, if subgraph fields or types include @requiresScopes, @authenticated, or @policy , they are set on the too.

Composition with AND/OR logic

If shared include multiple , merges them. For example, suppose the me requires @authentication in one :

Subgraph A
type Query {
me: User @authenticated
}
type User {
id: ID!
username: String
email: String
}

and the read:user scope in another :

Subgraph B
type Query {
me: User @requiresScopes(scopes: [["read:user"]])
}
type User {
id: ID!
username: String
email: String
}

A request would need to both be authenticated AND have the required scope. Recall that the @authenticated only checks for the existence of the apollo_authentication::JWT::claims key in a request's context, so authentication is guaranteed if the request includes scopes.

If multiple shared include @requiresScopes, the merges them with the same logic used to combine scopes for a single use of @requiresScopes. For example, if one requires the read:others scope on the users :

Subgraph A
type Query {
users: [User!]! @requiresScopes(scopes: [["read:others"]])
}

and another requires the read:profiles scope on users :

Subgraph B
type Query {
users: [User!]! @requiresScopes(scopes: [["read:profiles"]])
}

Then the would require both scopes for it.

Supergraph
type Query {
users: [User!]! @requiresScopes(scopes: [["read:others", "read:profiles"]])
}

As with combining scopes for a single use of @requiresScopes, you can use nested arrays to introduce OR logic:

Subgraph A
type Query {
users: [User!]! @requiresScopes(scopes: [["read:others", "read:users"]])
}
Subgraph B
type Query {
users: [User!]! @requiresScopes(scopes: [["read:profiles"]])
}

Since both scopes arrays are nested arrays, they would be composed using OR logic into the :

Supergraph
type Query {
users: [User!]! @requiresScopes(scopes: [["read:others", "read:users"], ["read:profiles"]])
}

This syntax means a request needs either (read:others AND read:users) scopes OR just the read:profiles scope to be authorized.

Authorization and @key fields

The @key directive lets you create an whose resolve across multiple . If you use authorization on fields defined in @key directives, Apollo still uses those to compose entities between the , but the client cannot them directly.

Consider these example :

Product subgraph
type Query {
product: Product
}
type Product @key(fields: "id") {
id: ID! @authenticated
name: String!
price: Int @authenticated
}
Inventory subgraph
type Query {
product: Product
}
type Product @key(fields: "id") {
id: ID! @authenticated
inStock: Boolean!
}

An unauthenticated request would successfully execute this :

query {
product {
name
inStock
}
}

Specifically, under the hood, the would use the id to resolve the Product , but it wouldn't return it.

For the following , an unauthenticated request would resolve null for id. And since id is a non-nullable , product would return null.

query {
product {
id
username
}
}

This behavior resembles what you can create with contracts and the @inaccessible directive.

Authorization and interfaces

If a type implementing an interface requires authorization, unauthorized requests can the interface, but not any parts of the type that require authorization.

For example, consider this schema where the Post interface doesn't require authentication, but the PrivateBlog type, which implements Post, does:

type Query {
posts: [Post!]!
}
type User {
id: ID!
username: String
posts: [Post!]!
}
interface Post {
id: ID!
author: User!
title: String!
content: String!
}
type PrivateBlog implements Post @authenticated {
id: ID!
author: User!
title: String!
content: String!
publishAt: String
allowedViewers: [User!]!
}

If an unauthenticated request were to make this :

query {
posts {
id
author
title
... on PrivateBlog {
allowedViewers
}
}
}

The would filter the as follows:

query {
posts {
id
author
title
}
}

The response would include an "UNAUTHORIZED_FIELD_OR_TYPE" error at the /posts/@/allowedViewers path.

Query deduplication

You can enable query deduplication in the to reduce redundant requests to a . The router does this by buffering similar queries and reusing the result.

Query deduplication takes authorization into account. First, the groups unauthenticated queries together. Then it groups authenticated queries by their required scope set. It uses these groups to execute queries efficiently when fulfilling requests.

Introspection

is turned off in the by default, as is best production practice. If you've chosen to enable it, keep in mind that authorization directives don't affect introspection. All that require authorization remain visible. However, applied to fields aren't visible. If might reveal too much information about internal types, then be sure it hasn't been enabled in your configuration.

With turned off, you can use 's schema registry to explore your and empower your teammates to do the same. If you want to completely remove from a graph rather than just preventing access (even with on), consider building a contract graph.

Configuration options

The behavior of the authorization plugin can be modified with various options.

reject_unauthorized

The reject_unauthorized option configures whether to reject an entire if any authorization failed, or any part of the query was filtered by authorization directives. When enabled, a response contains the list of paths that are affected.

router.yaml
authorization:
directives:
enabled: true
reject_unauthorized: true # default: false

errors

By default, when part of a is filtered by authorization, the list of filtered paths is added to the response and logged by the . This behavior can be customized for your needs.

log

By enabling the log option, you can choose if filtering will result in a log event being output.

router.yaml
authorization:
directives:
errors:
log: false # default: true

NOTE

The log option should be disabled if filtering parts of queries according to the client's rights is approved as normal by platform operators.

response

You can configure response to define what part of the response should include filtered paths:

  • errors (default) : place filtered paths in errors
  • extensions: place filtered paths in . Useful to suppress exceptions on the client side while still giving information that parts of the were filtered
  • disabled: suppress all information that the was filtered.
router.yaml
authorization:
directives:
errors:
response: "errors" # possible values: "errors" (default), "extensions", "disabled"

dry_run

The dry_run option allows you to execute authorization without modifying a , and evaluate the impact of authorization policies without interfering with existing traffic. It generates and returns the list of unauthorized paths as part of the response.

router.yaml
authorization:
directives:
enabled: true
dry_run: true # default: false
Previous
JWT Authentication
Next
Subgraph Authentication
Edit on GitHubEditForumsDiscord

© 2024 Apollo Graph Inc.

Privacy Policy

Company