Using Cognito groups to control access to API endpoints

User groups in Cognito provide a simple way to control access to different endpoints. It's a serverless solution that we can set up in a few minutes.

1. Scenario

It’s a common scenario that the users of an application should access different endpoints based on their permission level.

Example Corp. has a movie application where users can decide if they want to get information about movies or shows based on their preference.

Our users will have a username and password. They will then log in to the application and access either the GET /movies or the GET /shows endpoint. Users in the movies-group can access the /movies endpoint while /shows will deny their request. Similarly, shows-group users will get a successful response from the /shows endpoint but will be disappointed when they want to get the movies.

2. Solution overview

The company has built their system using AWS products, so it seems reasonable to use Amazon Cognito for setting up the authentication flow and access control.

Control access to API with Cognito groups - movies
Control access to API with Cognito groups - movies

We will store user data in a Cognito user pool, which has two groups, movies-group and shows-group. User Alice likes movies, and she will be in the movies-group. On the other hand, Bob prefers shows, and we will place him into the shows-group.

When the user logs in to the application with their username and password, Cognito will return a one-time token as a query string in the callback URL. The application will then exchange this code for the tokens that contain the group information.

The application will then call an HTTP API created with API Gateway. The API has two endpoints, GET /movies and GET /shows, which return exciting information about movies and shows, respectively.

We will protect both endpoints with a custom authorizer, which is a Lambda function. The authorizer will verify, decode and extract the group information from the token, and allows or denies the request.

3. Details

Let’s take a closer look at some of the steps in this pattern.

3.1. Pre-requisites

This post won’t explain how to create

  • an HTTP API
  • Lambda functions
  • backend Lambda integrations and Lambda authorizers for the API
  • a Cognito user pool with hosted UI, Cognito domain and callback URL.

I’ll provide some links at the end of the post that will help spin up these resources if needed.

3.2. Creating users and groups

Let’s create two users, Alice and Bob, and assign them passwords in the Cognito user pool. We will also need two groups, movies-group and shows-group. Add Alice to the movies-group and Bob to the shows-group.

3.3. User login

Let Alice sign in by entering the following address into the browser:

https://USER_POOL_NAME.auth.us-east-1.amazoncognito.com/login?
  response_type=code&
  client_id=APP_CLIENT_ID&
  redirect_uri=http://localhost:3000/app

response_type=code means we want an authentication code in the response and no tokens. It is more secure than making the token visible to the user in the browser.

We should also specify the app client_id. We generated the app client when we were creating the user pool. The app client will call the authorization server on our behalf, so we have to define its id in the request.

The last part of the URL is the redirect_uri. It’s the same as we specified when we created the user pool. In this case, let’s make it http://localhost:3000/app.

Calling this URL from the browser will redirect us to the hosted UI. Here we can enter Alice’s username and password.

Cognito will ask us to change the password, and then it will redirect us to localhost:3000/app, which is the redirect URL that we specified when we created the user pool.

The redirect URL in the browser’s address bar will look like this:

http://localhost:3000/app?
  code=463e3e32-d19d-4c51-9010-a56361232e89

As we can see, Cognito has appended the authorization code to the redirect URL.

3.4. Running an application on localhost:3000

I just span up a quick React app and created the /app page. If you start the app with npm start, it will display the landing page on localhost:3000, so Cognito can redirect the user to localhost:3000/app.

3.5. Getting the tokens

The problem is that API Gateway won’t understand the authorization code. We will have to get a token instead and submit the request with it.

We can exchange the authorization code to ID, access and refresh tokens.

This process occurs at the application level and not in the browser. This way, users can’t see the tokens, which adds an extra layer of security to the process.

The application extracts the authorization code from the URL and makes a POST request to the https://USER_POOL_NAME.auth.us-east-1.amazoncognito.com/oauth2/token endpoint. The body of the request should be in x-www-form-urlencoded format and must have the following payload:

{
  "grant_type": "authorization_code",
  "code": "463e3e32-d19d-4c51-9010-a56361232e89", // authorization code
  "client_id": "APP CLIENT ID HERE",
  "redirect_uri": "http://localhost:3000/app" // same as above
}

If we added a secret to the app client when we created it, we must include both the client id and the secret in the request. We should send them Base64 encoded in CLIENT_ID:CLIENT_SECRET format in the Authorization header:

{
  "Authorization": "Basic Base64(CLIENT_ID:CLIENT_SECRET)"
}

If there’s no secret added to the app client (recommended for web applications), we won’t have to add the Authorization header.

We can use the authorization code only once. If we try to submit the request with the same code again, we will get an invalid grant error.

We can now simulate the flow by firing the request from Postman. The response should look like this:

{
  "id_token": "ID TOKEN",
  "access_token": "ACCESS TOKEN",
  "refresh_token": "REFRESH TOKEN",
  "expires_in": 3600, // default value of 1 hour
  "token_type": "Bearer"
}

Both the ID and access tokens are JSON Web Tokens (JWT) and contain the group information as a claim. I will cover the difference between ID and access tokens in another article.

The claim we are most interested in is the cognito:groups, which will be an array:

{
  "cognito:groups": ["movies-group"]
}

We can use this information to control access to the backend endpoints.

3.6. Backend - authorizer code

Let’s highlight some parts of the custom authorizer code.

As discussed earlier, we will have a Lambda authorizer that verifies the token and decides if the requested path (/movies or /shows) belongs to the user’s Cognito group.

First, we can create an array of objects that map the groups to the paths:

const mapGroupsToPaths = [{
  path: '/movies',
  group: 'movies-group'
}, {
  path: '/shows',
  group: 'shows-group'
}];

We can store this information in an external database and fetch it from there. For the sake of simplicity, and because this example has only two routes, let’s store the map in memory.

The handler of the authorizer function can look like this:

const { CognitoJwtVerifier } = require('aws-jwt-verify');

exports.handler = async function(event) {
  // get the requested path from the API Gateway event
  const requestPath = event.requestContext.http.path
  const existingPaths = mapGroupsToPaths.map((config) => config.path)
  if (!existingPaths.includes(requestPath)) {
    console.log('Invalid path')
    return {
      isAuthorized: false
    }
  }

  const authHeader = event.headers.authorization
  if (!authHeader) {
    console.log('No auth header')
    return {
      isAuthorized: false
    }
  }
  const token = authHeader.split(' ')[1] // header has a 'Bearer TOKEN' format

  // the package verifies the token
  // specify if you want to verify ID or access token
  const verifier = CognitoJwtVerifier.create({
    userPoolId: 'USER POOL ID',
    tokenUse: 'access', // or tokenUse: 'id' for ID tokens
    clientId: 'APP CLIENT ID',
  });

  let payload
  try {
    payload = await verifier.verify(token);
    console.log('Token is valid. Payload:', payload);
  } catch {
    console.log('Token not valid!');
    return {
      isAuthorized: false
    }
  }

  const matchingPathConfig = mapGroupsToPaths.find(
    (config) => requestPath === config.path
  )
  const userGroups = payload['cognito:groups']
  if (userGroups.includes(matchingPathConfig.group)) {
    return {
      isAuthorized: true
    }
  }

  return {
    isAuthorized: false
  }
}

The aws-jwt-verify package verifies the signature and decodes the token with just one line of code. Its verify method returns the payload of the decoded token. We must specify if we want to use an ID (tokenUse: 'id') or an access token (tokenUse: 'access'). If we call the endpoint with a different type of token from what we have specified in the authorizer code, we will receive an invalid token error. Although the ID and access tokens contain the group information, too, we will use the access token in this example.

The custom authorizer can return either an object or a policy for HTTP APIs. The returned object should be { isAuthorized: true/false } depending on the result of the authorization. Returned policies are the same as in the case of a REST API. I found returning the object easier than generating and responding with a policy.

3.7. It should work!

We can now test if Alice and Bob can get a valid response for their movie and show inquiry.

We should have Alice’s tokens, so let’s put the access token in the Authorization header and call the endpoint from Postman:

GET https://API_GW_INVOKE_URL/movies

We should get a valid response because we assigned Alice to the movies-group in Cognito.

If we try calling the /shows endpoint, we will get a 403 Forbidden error.

If we sign in with Bob’s credentials, we will receive a successful response for the /shows endpoint and 403 for the /movies.

3.8. A user can be in multiple groups

If Alice decides to extend her interests and wants to start watching shows, we can add her to the shows-group in Cognito. Alice is now in both groups, and her access token will reflect that (she will need to sign in again):

"cognito:groups": [
  "movies-group",
  "shows-group"
]

She can now receive success responses from both the /movies and /shows endpoints.

4. Summary

We can create groups in Cognito and add users to the groups. Cognito will place the group information on the ID and access tokens.

If we have an HTTP API with our endpoints, we can use a custom authorizer that verifies the token. The Lambda authorizer can extract the group information from the token payload and return a response object with the authorization result.

5. Further reading

Working with HTTP APIs - Official documentation about HTTP API

Choosing between REST APIs and HTTP APIs - Comparison with lots of tables

Working with AWS Lambda proxy integrations for HTTP APIs - Adding a Lambda integration to the HTTP API

Building Lambda functions with Node.js - How to create and deploy a Lambda function

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