Fine-grained access control in API Gateway with Cognito access token and scopes

Yan Cui

I help clients go faster for less using serverless technologies.

In security and access control, authentication and authorization are two distinct yet interconnected concepts.

Authentication is the process of confirming the identity of a user or system, while authorization defines the actions that the authenticated user is permitted to perform within your system.

Although API Gateway integrates directly with Cognito, it lacks built-in support for fine-grained authorization.

In a previous article, we looked at implementing fine-grained authorization using a Lambda authorizer and Cognito groups. In that approach, authorization takes place on the API side: the caller sends its ID token to the API, and the Lambda authorizer generates a policy based on the user’s groups, which are embedded in the ID token by default.

This time, we’ll look at a different approach – using access tokens with scopes. In this setup, the identity provider (Cognito, in our case) manages both authentication and authorization, offloading these responsibilities from the API.

Customizing Cognito access tokens

As of December 2023, Cognito supports customizing access tokens [1]. Previously, you could only customize the ID tokens with the Pre-Token Generation trigger [2].

This new capability lets you customize the access tokens by adding specific scopes [3]. Here’s how:

1. Enable Advanced Security Features: Turn on this setting in the user pool.

2. Configure the Pre-Token Generation trigger: Choose “Basic features + access token customization” in the “Trigger event version”. Note: CloudFormation doesn’t support this setting and requires manual configuration.

3. Implement the pre-token generation Lambda function: Use this function to add custom scopes to the access token.

Assuming you have two groups in the user pool – Admin and ReadOnly.

You want these groups to correspond to the api/admin and api/readonly scopes, respectively, which will control access to the GET /admin and GET /readonly endpoints.

Your pre-token generation Lambda function might look like the following. Where we map the group names to an array of scopes.

const GROUPS_TO_SCOPES = {
  'Admin': ['api/admin'],
  'ReadOnly': ['api/readonly']
}

/**
 * @param {import('aws-lambda').PreTokenGenerationV2TriggerEvent} event 
 * @returns {Promise<import('aws-lambda').PreTokenGenerationV2TriggerEvent}
 */
module.exports.handler = async (event) => {
  const groups = event.request.groupConfiguration.groupsToOverride || [];

  const scopes = groups.map(gr => GROUPS_TO_SCOPES[gr]).flat();

  event.response = {
    claimsAndScopeOverrideDetails: {
      accessTokenGeneration: {
        scopesToAdd: ['openid', 'profile', ...scopes],
      }
    }
  };

  return event;
}

The claimsAndScopeOverrideDetails object tells Cognito what scopes to add to the access token. When a user in the Admin group signs in, its access token will looks something like this:

{
  "sub": "a4580468-40a1-70e8-7b19-d4f5e323ef61",
  "cognito:groups": [
    "Admin"
  ],
  "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_iSMheGjAF",
  "client_id": "32dv5hrfiuk9lj4j13t0tfb6be",
  "origin_jti": "27cd2239-92cc-413b-9947-256919ef4332",
  "event_id": "2efe1a9a-c1f6-420e-a90f-13b4a31105f2",
  "token_use": "access",
  "scope": "api/admin aws.cognito.signin.user.admin openid profile",
  "auth_time": 1726334099,
  "exp": 1726337699,
  "iat": 1726334099,
  "jti": "2a7992cc-8f49-4363-9738-4814b771896f",
  "username": "a4580468-40a1-70e8-7b19-d4f5e323ef61"
}

Map scopes to API Gateway routes

To ensure API Gateway respects these scopes, configure your API Gateway methods with an AuthorizationScopes array.

These scopes are used with a Cognito authorizer to authorize a user request. A user request is authorized if any of the AuthorizationScopes matches a scope in the access token.

Here’s how you can configure the GET /admin and GET /readonly endpoints:

// GET /admin
const adminResource = api.root.addResource('admin')
adminResource.addMethod('GET', mockIntegration, {
  authorizer: {
    authorizationType: AuthorizationType.COGNITO,
    authorizerId: authorizer.ref,        
  },
  authorizationScopes: ['api/admin']
});

// GET /readonly
const readonlyResource = api.root.addResource('readonly');
readonlyResource.addMethod('GET', mockIntegration, {
  authorizer: {
    authorizationType: AuthorizationType.COGNITO,
    authorizerId: authorizer.ref
  },
  authorizationScopes: ['api/readonly']
});

For a working demo, check out this repo [4].

Considerations

This is an elegant solution. There is a lot to like about it. But it has one BIG downside – cost.

The cost implication

Enabling Advanced Security Features significantly increases the cost of using Cognito:

1. 10x increase in cost: Advanced Security Features cost ten times more than the standard monthly active users (MAU) pricing.

2. No free tier: Unlike the standard MAU cost, which includes a generous free tier of 50,000 MAU, there is no free tier for Advanced Security Features.

3. Still have to pay the standard MAU cost: The Advanced Security Features cost is charged in addition to the standard MAU cost, although you benefit from the 50,000 MAU free tier.

The cost overhead is substantial when you enable Advanced Security Features, as detailed in my last post [5].

At 50,000 MAU, you’d pay $2,500 per month, vs. $0 if you use ID tokens for authorization.

At 100,000 MAU, it becomes $4525 (using access tokens) vs. $275 (ID tokens).

Sadly, other vendors would charge you even more, as one of Cognito’s greatest strengths is its cost-efficiency.

Are access tokens more secure than ID tokens?

Proponents of this approach argue that access tokens should be used for authorization and ID tokens for authentication.

While this might be technically accurate, it has little practical impact. As discussed in my last post [5], ID tokens are just as secure as access tokens.

Is this approach faster than Lambda authorizers?

Another argument is that this approach is more performant since it eliminates the need for Lambda authorizers and their associated cold starts.

However, since you still need a Lambda function to customize the access token, the potential for cold starts remains on the critical path.

Conclusion

It’s nice that this approach keeps your authorization logic within the Cognito User Pool setup. It centralizes both authentication and authorization concerns in one place.

However, the cost implications are too significant.

Unless you’re using Advanced Security Features already, or your application has a high value per user (e.g. a B2B enterprise application), this approach may be difficult to justify in terms of return on investment.

Links

[1] Cognito now support the ability to customize access tokens

[2] Cognito’s Pre-Token Generation trigger

[3] How to customize access tokens in Amazon Cognito user pools

[4] Demo repo

[5] Is it safe to use ID tokens with Cognito authorizers?

Related posts

Whenever you’re ready, here are 4 ways I can help you:

  1. Production-Ready Serverless: Join 20+ AWS Heroes & Community Builders and 1000+ other students in levelling up your serverless game. This is your one-stop shop for quickly levelling up your serverless skills.
  2. Do you want to know how to test serverless architectures with a fast dev & test loop? Check out my latest course, Testing Serverless Architectures and learn the smart way to test serverless.
  3. I help clients launch product ideas, improve their development processes and upskill their teams. If you’d like to work together, then let’s get in touch.
  4. Join my community on Discord, ask questions, and join the discussion on all things AWS and Serverless.