Join us for GraphQL Summit, October 10-12 in San Diego. Use promo code ODYSSEY for $400 off your pass.
Docs
Launch GraphOS Studio
Since 1.29.1

Authorization in the Apollo Router

Strengthen service security with a centralized governance layer


This feature is available only with a GraphOS Enterprise plan. If your organization doesn't currently have an Enterprise plan, you can test this functionality by signing up for a free Enterprise trial.

This feature is currently in preview. Your questions and feedback are highly valueddon't hesitate to get in touch with your Apollo contact or on the official Apollo GraphQL Discord.

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 graph 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 subquery 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 s based on their required authorization. Entire groups can be eliminated from the query plan 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 graph-native governance without the need for an extra orchestration layer.

  • Principled architecture: Through composition, 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 Apollo provides access controls via authorization directives that define access to specific s 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. composes restrictions into the so that each 's restrictions are respected. The then enforces these s on all incoming requests.

Prerequisites

⚠️ Only the Apollo Router supports authorization directives@apollo/gateway does not. Check out the migration guide if you'd like to use them.

Before using the authorization s in your s, 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 s use a request's claims to evaluate which s 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 Apollo 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 Apollo '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

While in preview, authorization s are turned off by default. To enable them, include the following in your 's YAML config file:

router.yaml
authorization:
preview_directives:
enabled: true

@requiresScopes

The @requiresScopes marks s 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"]])

Depending on the scopes present on the request, the filters out unauthorized s 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"])

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 may look something 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 query differently. If the request includes only the read:others scope, then the executes the following filtered query:

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 query successfully if the request includes the read:others read:emails scope set.

@authenticated

The @authenticated marks specific s 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 query in its entirety. If the request is unauthenticated, the router removes @authenticated s before planning the query 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"])

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 query for an authenticated user's information.

The relevant part of your may look something 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 query:

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

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

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 query, nor the views for the post with id: "1234". The response retains the initial request's shape but returns null for unauthorized s 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 query is unauthorized.

Composition and federation

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

Composition with AND/OR logic

If shared s include multiple s, composition merges them. For example, suppose the me query 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 s 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 query:

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

and another requires the read:profiles scope on users query:

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 entity whose s resolve across multiple s. If you use authorization s on fields defined in @key directives, Apollo still uses those s to compose entities between the s, but the client cannot query them directly.

Consider these example s:

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:

query {
product {
name
inStock
}
}

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

For the following query, 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 query the interface, but not any parts of the type that require authorization.

For example, consider this 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:

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

The would filter the query 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 s that require authorization remain visible. However, s 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 s from a graph rather than just preventing access (even with on), consider building a contract graph.

Previous
JWT Authentication
Next
Subgraph Authentication
Edit on GitHubEditForumsDiscord