Using customized access tokens to set up authorization in API Gateway

We can easily add custom scopes to access tokens after the user has authenticated with a new Cognito user pools feature. Therefore, we can achieve fine-grained access control to various API endpoints with minimal work while delegating the authorization task to the API Gateway.

1. The scenario

Alice recently had to set up an authorization mechanism for the company’s internal application, which has hundreds of users. The application consists of microservices and an Amazon API Gateway protects the backend resources running the business logic.

Alice’s main problem is that the API Gateway contains dozens of endpoints, and almost every endpoint should have its permission!

The following list contains part of the requirements:

  • GET /pets (endpoint) - List all pets (description) - ReadPetsAccess (permission)
  • POST /pets (endpoint) - Add a pet (description) - WritePetsAccess (permission)

The list contains a few dozen more endpoints and permissions.

She must ensure that only users with the relevant permission can access each endpoint.

Alice is aware that API Gateway supports authorization with Cognito user pool tokens. She also knows that an ideal solution would be to define a required scope which maps to a specific permission in the given endpoint’s configuration and have the access token contain that scope. In this case, API Gateway would handle authorization, and the backend code would only take care of what it’s supposed to, which is running the business logic.

2. Current solution

Until recently, this solution was only partially possible to do. Sure, we could define the required scope in the Method request setting in the API Gateway configuration. However, the scope property in the access token was not customizable based on the specific authenticated user. The authentication request must have claimed all defined scopes so that they are present in the access token. It made the user- or permission-based access control hard to implement because the token contained all scopes for everyone.

So instead, Alice decided to create a Lambda authorizer that validates the authorization token and allows or denies the request accordingly based on the assigned Cognito groups in the token.

3. A new Cognito feature to the rescue

But it’s a lot of work with some extra dependencies in the code package. Lambda authorizer functions have their well-deserved place in our AWS developer toolbox, but we can now simplify the authorization process. The new Cognito user pools feature makes it possible to customize the access token’s scope property. With this, we can finally have API Gateway’s built-in feature to authorize requests in a secure way with access tokens.

It means that we can now remove the Lambda authorizer from the architecture, add the required scope to the access token and configure API Gateway to look for that scope in the token for each endpoint!

4. Main steps

This post won’t go over the entire process. Instead, I will highlight some key points.

4.1. Create a custom scope for each permission

We can create a custom OAuth2.0 scope for each permission in the Resource server section in Cognito user pools. So we can modify our list accordingly:

  • GET /pets (endpoint) - ReadPetsAccess (permission) - petsapi/read (scope)
  • POST /pets (endpoint) - WritePetsAccess (permission)- petsapi/write (scope)

4.2. Configure the API Gateway

Next, we configure API Gateway to use the access token for authorization.

Configure the required scope in API Gateway
Configure the required scope in API Gateway

We go to the Method request of the GET /pets endpoint and define the petsapi/read authorization scope. API Gateway will validate the token then and look for the petsapi/read value in the scope property. If the access token contains this scope, API Gateway will let the request proceed, otherwise it will deny it.

With this step, we automatically set up API Gateway to expect an access token instead of an ID token (ID tokens don’t have the scope property). After we have deployed the API, the endpoint will reject requests with no tokens, ID tokens or access tokens without scope: petsapi/read.

4.3. Enable Advanced security

The new access token customization will only work if we enable Advanced security in the Cognito user pool. It’s a paid feature which currently costs $0.05 per Monthly Active User in the Frankfurt region.

4.4. Customize the access token with the pre token generator

We can now build a pre token generation Lambda function to modify the access token.

Create pre token generation trigger
Create pre token generation trigger

The function will run after the user has authenticated (so we know who it is) but before Cognito generates the tokens. We can use the function to add and remove scopes from the access token or modify the ID token. We should select the Basic features + access token customization option here.

Configure access token customization
Configure access token customization

The function’s logic will add the petsapi/read and petsapi/write values to the access token’s scope property if the user has ReadPetsAccess and WritePetsAccess permissions, respectively.

5. Code example

The Lambda function code can look like this:

import { PreTokenGenerationV2TriggerEvent } from 'aws-lambda';

// 1. Map the scopes to the permissions
const permissionToScopeMap: Record<string, string> = {
  ReadPetsAccess: 'petsapi/read',
  WritePetsAccess: 'petsapi/write',
  ...
};

export const handler = async (event: PreTokenGenerationV2TriggerEvent): Promise<PreTokenGenerationV2TriggerEvent> => {
  // 2. Get the permissions from the token
  const userGroups = event.request.groupConfiguration.groupsToOverride;
  if (!userGroups) {
    console.log('No group is configured to the user');
    return event;
  }

  // 3. Retrieve the scopes corresponding to the user's permissions
  const userScopes = userGroups.map((group) => {
    return permissionToScopeMap[group];
  });

  // 4. Add the customized scope to the token
  const response: PreTokenGenerationV2TriggerEvent = {
    ...event,
    response: {
      claimsAndScopeOverrideDetails: {
        accessTokenGeneration: {
          scopesToAdd: ['openid', 'profile', ...userScopes],
        },
      },
    },
  };

  return response;
};

First, we map the permissions to the custom scopes. In this case, I have an object for the sake of simplicity. But we can store the mappings in a separate config file or externally, for example, in Parameter Store.

Next, we retrieve the permission we have already configured in Cognito user pools as groups. For this example, it doesn’t matter what IAM permissions we assign to the groups because we don’t use the role. It can be a blank role with no permissions. We use the groups to know which user needs which scopes. A user can belong to multiple groups in Cognito, so the user’s access token will contain all corresponding scopes.

The function’s return value must be the original event object with all changes we want to make on the token. In this case, we should write our code to expect the Version 2 of the pre token generation trigger parameters as input.

6. Considerations

I believe the new access token customization feature will simplify how we control access to API endpoints. There’s no need for workarounds as it’s plain and simple. We don’t have to set up Lambda authorizers since we can now use API Gateway’s built-in token validation feature. The trade-offs are that it’s a paid feature, and we will still have a Lambda function running as the pre token generator trigger.

7. Summary

Cognito access tokens are now customizable! It means that we can add or remove claims and scopes from them. The new feature simplifies fine-grained access control to our endpoints.

With the help of a pre token generator Lambda trigger function, we can add the scope the authenticated user needs to the access token. Then, the API Gateway will validate the token, look for the required scope, and allow or deny the request based on the scope property.

8. Further reading

OAuth 2.0 scopes and API authorization with resource servers - How to create custom scopes in Cognito user pools

Adding advanced security to a user pool - The title says it all

Amazon Cognito Pricing - Cognito pricing page

Getting started with user pools - How to create a user pool with an app client

Adding groups to a user pool - Straighforward title from AWS

Creating a REST API in Amazon API Gateway - How to create a REST API in API Gateway

Pre token generation Lambda trigger - With more examples