Secure an AWS API Gateway With Amazon Cognito and AWS Lambda
How to use Amazon Cognito user pools with a Python AWS Lambda back end to secure an AWS API Gateway endpoint

Table of Contents
- Introduction
- A brief about OAuth 2.0
- Requirements
- Creating the Amazon Cognito user pool
- Creating the user pool
- Adding a user
- Creating an App Client
- Configuring the App Client Identity Providers
- Adding a user — cont.
- Using the built-in form to register users
- Setting up an authorization endpoint
- Creating the authorization Lambda function
- Writing the function code
- Granting Cognito access to the function
- Testing the function
- Creating an authorization endpoint
- Setting up the AWS API Gateway Authorization
- Creating an authorizer
- Setting up our endpoint authorization
- Testing the Secure API Gateway
- Testing the authorization endpoint
- Testing the secure endpoint
- Summary
Introduction
Being part of several organizations that handle sensitive data, security has always been a thing for me to keep in mind when designing architectures, no matter how complex.
When it comes to an internet-facing endpoint that interacts with our cloud environment, security becomes the number one priority.
In my previous piece, I showed the basic implementation of an AWS API Gateway endpoint with a Python AWS Lambda back end. But an important thing I didn’t show is how to secure that endpoint.
AWS API Gateway has built-in integration with Amazon Cognito, a service that manages user pools and secure access to AWS services. This built-in integration makes it relatively easy to add security to your endpoints.
A brief about OAuth 2.0
Amazon Cognito uses the OAuth 2.0 protocol to authorize access to secure resources.
OAuth 2.0 uses access tokens to grant access to resources.
An access token is simply a string that stores information about the granted permissions. This token is usually valid for a short period of time, usually up to one hour, and can be refreshed using a password or a special refresh token.
A refresh token is usually obtained using password authentication. It’s valid for a longer time, sometimes indefinitely, and its whole purpose is to generate new access tokens. The refresh token can be used to generate an unlimited number of access tokens, until it is expires or is manually disabled.
With Amazon Cognito, the access token is referred to as an ID token, and it’s valid for 60 minutes. A refresh token is obtained as part of the user-pool app client (more on that later) and can be valid for up to 10 years.
Requirements
This guide only assumes that you have AWS as your cloud provider and that you have access to the Cognito, API Gateway, and Lambda management consoles.
I also assume you have some kind of AWS API Gateway endpoint already set up. My previous piece can walk you through setting up such an endpoint.
The solution is using an AWS Lambda function written in Python, but a similar solution is possible for other languages supported by AWS Lambda.
Creating the Amazon Cognito user pool
We’ll start by creating the Amazon Cognito user pool that’ll manage our users — along with the authentication method, the registration process, and many other security features.
Creating the user pool
Open the AWS Management Console, and from the Services menu, choose “Cognito.” In the Cognito main screen, select “Manage User Pools,” and on the next screen, click on “Create a user pool.” Type a name for your user pool and select “Review defaults.”
This will set up the user pool with the default attributes and security settings. In the next screen, you’ll be able to review and change the default settings. Leave everything as-is for now. We’ll change some of those defaults later. Click on “Create pool” to finish.
For additional information about user pool settings, check the developers guide here.
Adding a user
Once the pool is created, you get redirected to the user-pool management screen. Now we can add the first user to our pool.
In the General settings section, select “Users and groups,” and click on “Create user.” In the next screen, type a username, and click on “Create user.”
You can also send an invitation to the new user (and define the phone number and email address accordingly) and also define a temporary password for the user (otherwise, a temporary password will be autogenerated and provided by email/SMS).
Once the user is created, the account status is automatically set to FORCE_CHANGE_PASSWORD. The user must sign in and set a permanent password in order to confirm their account. We need to set up an app client for that purpose.
Creating an app client
An app client acts as an authorization endpoint for your user pool. It also provides users the ability to register, log in, and manage their account.
In the user-pool management screen, select “App clients” in the General settings section, and then click on “Add an app client.”
Type a name, and make sure “Generate client secret” and “Enable sign-in API for server-based authentication (ADMIN_NO_SRP_AUTH)” are checked (the client secret and the sign-in API are required for us to create an authentication Lambda function).
You can also modify the refresh token expiration period (it defaults to 30 days but can be set to up to 10 years). Click on “Create app client” to finish.
You can also define here which user attributes will be accessible by the app client (the default is set to all).
Configuring the app-client identity providers
Next, we need to configure the identity providers that’ll be used to authenticate our users.
We’re going to use a simple configuration that’ll allow the Cognito user pool itself to authenticate users. Alternatively, we can use a third-party provider for that (for additional information, check the developers guide here).
First, we need a domain to have our app client publicly accessible for registration and login. In the user-pool management screen, select “Domain name” in the app-integration section.
Type a subdomain name, and click “Save changes.” You can also configure your own domain for that purpose.
Now, in the user-pool management screen, select “App client settings” in the app-integration section.
Select the “Cognito User Pool” identity provider to enable it.
Type an URL you’d like to use to redirect users from the log-in/register page in the Callback URL section.
Select the “Authorization code grant” and “Implicit grant” OAuth flow.
Select “openid” under Allowed OAuth Scopes.
Those will allow us to create an AWS API Gateway endpoint that provides users with identity tokens and allows our users to log in and register using the built-in Cognito interface.
Click on “Save changes” when you finish.
Adding a user — cont.
Once we have an app client set up, we can use the log-in URL to confirm the user account we created earlier:
https://YOUR_SUB_DOMAIN.auth.us-east-1.amazoncognito.com/login?response_type=code&client_id=YOUR_APP_CLIENT_ID&redirect_uri=YOUR_CALLBACK_URL
Make sure to replace the placeholders with your own subdomain name, app client ID, and callback URL.
This URL assumes you use a built-in Cognito subdomain, but the behavior is similar for your own custom domain as well.
Go to your log-in URL, and type your username and a temporary password. Click on “Sign in.” You’ll then be prompted to change your temporary password. Once you do, you’ll be redirected to your callback URL.
If you check your account now in the users-management page, you’ll notice it is marked as “CONFIRMED” and can be used to access your secure environment.
Using the built-in form to register users
In addition to the log-in URL, you can allow users to register using a built-in sign up form.
Again, this URL assumes you use a built-in Cognito subdomain, but the behavior is similar for your own custom domain as well.
Go to your sign up URL, and type a username, an email address, and a password. Click on “Sign up.” You’ll then be asked to type a verification code that was sent to you by email. Type the code, and click on “Confirm account” to finish.
It’s possible to disable the sign-up form so users can only be added manually as we first did. To do that, go to the General settings page in the user-pools management screen, and select the small edit icon next to the section that has the “User sign ups allowed?” setting.
Make sure you select “Only allow administrators to create users,” and click on “Save changes.”
Setting Up an Authorization Endpoint
Next, we need to create an authorization endpoint that will provide our users with ID tokens that can be used to access other endpoints.
Creating the authorization Lambda function
Open the AWS Management Console, and from the Services menu, select “Lambda.”
In the Lambda page, click on “Create function.” Choose “Author from scratch,” type a name, and select “Python 3.6” or “Python 3.7” runtime. Expand the Permissions section, and choose “Create a new role with basic Lambda permissions.” We’ll handle the required permissions later.
Writing the function code
Once your function is created, you’ll see the function-management screen, where you handle configuration, test the function, and, of course, write the code.
The Python Lambda code editor comes with a default lambda_function.py
file with a lambda_handler
method. We’ll use this default file and method for our example.
In the code editor, delete the content of the lambda_function.py
file, and type the following code instead:
import boto3
import hmac
import hashlib
import base64USER_POOL_ID = 'TYPE_USER_POOL_ID_HERE'
CLIENT_ID = 'TYPE_APP_CLIENT_ID_HERE'
CLIENT_SECRET = 'TYPE_APP_CLIENT_SECRET_HERE'client = Nonedef get_secret_hash(username):
msg = username + CLIENT_ID
digest = hmac.new(str(CLIENT_SECRET).encode('utf-8'), msg=str(msg).encode('utf-8'), digestmod=hashlib.sha256).digest()
dec = base64.b64encode(digest).decode()
return decdef initiate_auth(username, password):
try:
resp = client.admin_initiate_auth(
UserPoolId=USER_POOL_ID,
ClientId=CLIENT_ID,
AuthFlow='ADMIN_NO_SRP_AUTH',
AuthParameters={
'USERNAME': username,
'SECRET_HASH': get_secret_hash(username),
'PASSWORD': password
},
ClientMetadata={
'username': username,
'password': password
})
except client.exceptions.NotAuthorizedException as e:
return None, "The username or password is incorrect"
except client.exceptions.UserNotFoundException as e:
return None, "The username or password is incorrect"
except Exception as e:
print(e)
return None, "Unknown error"
return resp, None
def refresh_auth(username, refresh_token):
try:
resp = client.admin_initiate_auth(
UserPoolId=USER_POOL_ID,
ClientId=CLIENT_ID,
AuthFlow='REFRESH_TOKEN_AUTH',
AuthParameters={
'REFRESH_TOKEN': refresh_token,
'SECRET_HASH': get_secret_hash(username)
},
ClientMetadata={ })
except client.exceptions.NotAuthorizedException as e:
return None, "The username or password is incorrect"
except client.exceptions.UserNotFoundException as e:
return None, "The username or password is incorrect"
except Exception as e:
print(e)
return None, "Unknown error"
return resp, Nonedef lambda_handler(event, context):
global client
if client == None:
client = boto3.client('cognito-idp') username = event['username']
if 'password' in event:
resp, msg = initiate_auth(username, event['password'])
if 'refresh_token' in event:
resp, msg = refresh_auth(username, event['refresh_token']) if msg != None:
return {
'status': 'fail',
'msg': msg
}
response = {
'status': 'success',
'id_token': resp['AuthenticationResult']['IdToken']
}
if 'password' in event:
response['refresh_token'] = resp['AuthenticationResult']['RefreshToken']
return response
This function receives a username and either a password or a refresh token:
- If a password is provided, the response includes an ID token and a refresh token
- If a refresh token is provided, the response includes an ID token only
Don’t forget to replace the placeholders with data from the user-pool management screen:
- The user-pool ID can be taken from the General settings page
- The app-client ID and secret can be taken from the app-clients page in the General settings section
Granting Cognito access to the function
For our Lambda function to work, we need to grant it access to Cognito.
In the function-management page, go to the Execution role section, and click on “View the … role” on the IAM console.
In the role IAM page, click on “Attach policies.” Select the AmazonCognitoPowerUser policy, and click on “Attach policy.”
You can also create a new policy that limits access to specific actions or a specific user pool, but since the access is hardcoded in your Lambda function, it’s safe to use the power-user policy here.
Testing the function
You can test your Lambda function using a sample message. In this case, we can use the user we created earlier in order to test access.
In the Lambda-function management page, click on “Test,” then select “Create new test event.” Type a name, and replace the sample data with a simple JSON object that has your username and password, as follows:
Click on “Create” to create the test event, and then in the function-management page, click on “Test” again to test your function. A successful test should give you a response similar to this:
Creating an authorization endpoint
Open the Services menu, and select “API Gateway.”
In the Resources page, open the Actions menu, and choose “Create Resource.” Type a resource name and path (for example, /oauth
), and click on “Create Resource.” Optionally, you can create another resource named token such that the full path to your resource will be /oauth/token
.
Then, open the Actions menu again, and this time choose “Create Method.” In the opened drop-down select “POST,” and click on the small V icon to confirm.
In the opened form, choose “Lambda Function” as the integration type, and select your newly created auth Lambda function by typing its name or ARN identifier. Click on “Save,” and confirm that you allow API Gateway to invoke your Lambda function.
Now, any POST request to /oauth/token
in your endpoint will invoke the Lambda function we created earlier. As explained earlier, sending username+password as parameters will give you an ID token and a refresh token, and sending username+refresh token will give you an ID token.
Setting up the AWS API Gateway Authorization
Next, we need to set up authorization for our AWS API Gateway endpoint using our Cognito user pool.
Creating an authorizer
Select the Authorizers page, and click on “Create New Authorizer.” Type a name, select “Cognito” as the type, and select your Cognito user pool. In the Token Source field, type “Authorization,” and click on “Create.”
You can now test your new authorizer by clicking on “Test.”
In the Authorization Token field, type the ID token returned when you tested your Lambda function. Remember this token is only valid for one hour, but you can test your Lambda function again to generate a new one, if necessary. Click on “Test” to test your authorizer, and you should see “Response Code: 200,” with details about the user whose ID token you used.
Setting up our endpoint authorization
Next, we need to attach our authorizer to our endpoint to get secure access.
Go back to the Resources page, and select the method of your API Gateway endpoint (for example, the POST method of the /upload endpoint we walked-through creating in the previous post).
In Authorization, select your new authorizer, and click on the small V to confirm. Note that you might need to refresh the page for your new authorizer to appear in the drop menu.
Finally, we need to deploy our latest API Gateway changes (the authorization endpoint and the attached authorization).
From the Actions menu, choose “Deploy API.” Select your current stage (for example, v1) or create a new one, and click on “Deploy.”
Testing the Secure API Gateway
You can test your newly deployed API Gateway endpoint using the cURL command or a request generator application, such as Postman.
Testing the authorization endpoint
The authorization endpoint should get a username+password or username+refresh token and return an ID token to use for the endpoints that require secure access.
For a cURL command, run something like the following example:
curl --request POST --data '{"username": "test", "password": "Password1!"}' https://YOUR_API_GATEWAY_ID.execute-api.us-east-1.amazonaws.com/v1/oauth/token
A successful execution should return the response sent by the Lambda function:
{
"status": "success",
"id_token": "eyJ…",
"refresh_token": "eyJ…"
}
Testing the secure endpoint
The secure endpoint should get an authorization header that contains your ID token. A request without an ID token will result with an “Unauthorized” response.
Assuming you’re using the endpoint we created in the previous piece, for a cURL command, run something like the following example:
curl --request POST -H 'Authorization: Bearer YOUR_ID_TOKEN_HERE' -H "Content-Type: application/pdf" --data-binary "@/path/to/your/file.pdf" https://YOUR_API_GATEWAY_ID.execute-api.us-east-1.amazonaws.com/v1/upload
A successful execution should return the response sent by the Lambda function:
{
statusCode": 200,
"body": {
"file_path": "sample.txt"
}
}
Summary
Security is the most important aspect to consider when opening your environment to the world. Your internet endpoint is probably the most vulnerable part of your cloud architecture, and you must make sure it gets as safe as possible.
This piece walked through adding basic security to your AWS API Gateway endpoint using an Amazon Cognito user pool. While this basic security covers most use cases and it definitely suffices, Amazon Cognito is a powerful tool that can be used to enhance the security of your endpoint (and cloud environment in general) by adding additional layers of authentication, MFA, and integration with several identity providers.
Additional documentation on Amazon Cognito user pools and different use cases can be found here.