Fine-grained access control in API Gateway with Cognito groups & Lambda authorizer

Yan Cui

I help clients go faster for less using serverless technologies.

This article is brought to you by

Don’t reinvent the patterns. Catalyst gives you consistent APIs for messaging, data, and workflow with key microservice patterns like circuit-breakers and retries for free.

Try the Catalyst beta

In security and access control, authentication and authorization mean two distinct but related things.

Authentication verifies the identity of a user or system.

Authorization determines what actions an authenticated user is allowed to perform in your system.

API Gateway has built-in integration with Cognito, but it doesn’t provide any fine-grained authorization out-of-the-box.

By default, a Cognito authorizer only checks if a user’s bearer token is valid and that the user belongs to the right Cognito User Pool.

There are many ways to implement fine-grained access control in API Gateway. Here are three that I have come across over the years:

  • Using Lambda authorizer with Cognito groups;
  • Using Cognito access tokens with OAuth scopes;
  • Using Lambda authorizer with Amazon Verified Permissions [1];

Over the next few weeks, let’s look at these approaches in-depth and then compare them at the end.

Today, let’s look at Lambda authorizer with Cognito groups.

Model roles with Cognito groups

In Cognito, you can use groups to model the different roles in your system, e.g. Admin, ReadOnly.

Users can belong to more than one group at once, just as they can have multiple roles within a system.

Cognito encodes the groups a user belongs to in the ID token. If you decode the ID token, you will see something like this:

{
  "sub": "f438b478-6031-70f3-a346-4f8e84e00b62",
  "cognito:groups": [
    "ReadOnly",
    "Admin"
  ],
  "email_verified": true,
  "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xxx",
  "cognito:username": "f438b478-6031-70f3-a346-4f8e84e00b62",
  "origin_jti": "e0d4077e-7092-45dd-ac13-1d60d382629b",
  "aud": "1u7c0elmc6v3qrr68s4vpo63sm",
  "event_id": "fd811c5a-5ac7-4644-92ae-a9738a33bd76",
  "token_use": "id",
  "auth_time": 1724807904,
  "exp": 1724811504,
  "iat": 1724807904,
  "jti": "5fa8be1d-411f-418d-8508-b6b8fe64ff9b",
  "email": "example@example.com"
}

Here, we can see the user belongs to both the Admin and ReadOnly groups.

Lambda authorizer to control access

A Lambda authorizer can use this information to generate its policy document. As a reminder, a Lambda authorizer can return a policy document like this:

{
  "principalId": "username",
  "policyDocument": {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Action": "execute-api:Invoke",
        "Effect": "Allow",
        "Resource": "arn:aws:execute-api:us-east-1:12345:xxx/dev/GET/resource"
      }
    ]
  }
}

So, we need to take the list of groups a user belongs to and turn them into a set of policy statements.

Mapping roles to policies

One approach is to keep a mapping in your code like this.

const POLICY_STATEMENTS = {
  "Admin": [{
    "Action": "execute-api:Invoke",
    "Effect": "Allow",
    "Resource": "arn:aws:execute-api:us-east-1:123456789012:xxx/dev/*"
  }],
  "ReadOnly": [{
    "Action": "execute-api:Invoke",
    "Effect": "Allow",
    "Resource": [ 
      "arn:aws:execute-api:us-east-1:123456789012:xxx/dev/GET/token",
      "arn:aws:execute-api:us-east-1:123456789012:xxx/dev/POST/task",
      ...
    ]
  }]
}

In many systems, there are a small number of roles that supersede each other. That is, they are hierarchical, and a higher role has all the permissions of a lower role plus some.

In this case, we need to find the most permissive role that the user has.

// assuming we have only two roles, Admin and ReadOnly
// and Admin supercedes Readonly
const statement = groups.includes("Admin")
  ? POLICY_STATEMENTS["Admin"]
  : POLICY_STATEMENTS["ReadOnly"]

return {
  "principalId": username,
  "policyDocument": {
    "Version": "2012-10-17",
    "Statement": statement
  }
}

But what if the roles are more lateral? That is, a user’s permissions are derived from all its roles.

Well, that’s easy enough to accommodate.

const statement = []

["Admin", "ReadOnly"].forEach(x => {
  if (groups.includes(x)) {
    POLICY_STATEMENTS[x].forEach(stm => statement.push(stm))
  }
})

return {
  "principalId": username,
  "policyDocument": {
    "Version": "2012-10-17",
    "Statement": statement
  }
}

Conclusion

This is my preferred approach for fine-grained access control in API Gateway, at least with simple use cases.

It’s easy to follow and test and makes no API calls (i.e. no extra latency overhead).

Furthermore, it does not require Cognito’s Advanced Security Features, which are charged at a much higher rate [2]. This makes it a very cost-efficient approach.

However, using a Lambda authorizer means you must think about cold starts and their impact on user experience.

Also, the roles and policies are static. Whilst it’s good enough for simple use cases, it cannot (easily) support more advanced use cases. For example, if you need to allow users to create custom roles while maintaining the tenant boundary.

Amazon Verified Permissions is a better fit for more advanced use cases. More on it later.

Links

[1] Amazon Verified Permissions service

[2] Cognito’s pricing page

Related Posts

Whenever you’re ready, here are 3 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. 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.
  3. Join my community on Discord, ask questions, and join the discussion on all things AWS and Serverless.