Introduction
In the third part of our series, we’ll secure our serverless API using AWS Cognito. So, what exactly is AWS Cognito?
Amazon Cognito is a user authentication and authorization service provided by AWS. It offers:
- Simple integration for sign-up, sign-in, and access control in web and mobile apps.
- Secure user account management, supporting:
- Social login providers like Google and Facebook.
- Traditional email/password authentication.
- Enterprise identity providers.
This makes Cognito a powerful tool for securing your applications without needing to build a custom authentication system from scratch.
In this part, we will:
- Add Cognito user pool for authentication.
- Secure existing endpoints via API Gateway Authorizer.
- Introduce two new endpoints for user sign-up and sign-in.
- Configure IAM policies for Lambda functions to interact with Cognito.
Let’s begin with the architectural overview and then proceed step by step.
P.S. If you haven’t read the previous articles in this series, we recommend checking them out:
- [101] Launch Your Own Secure Serverless API on AWS with Terraform
- [102] Enhancing Your AWS Serverless API with DynamoDB for Data Persistence
Enhancing the Architecture
Below is an updated high-level architecture diagram that illustrates the integration of Cognito with API Gateway and Lambda.
The source code for this part is available in the GitHub repository in the 103
directory. However, we strongly recommend following the tutorial to understand the concepts and steps, starting from the codebase created in the second [102]
part.
Setting Up Cognito
We’ll begin by creating an AWS Cognito User Pool to manage user authentication.
Terraform Configuration for Cognito
Create a new cognito module in your terraform/modules directory.
Define the following files:
main.tf
: This file will set up the Cognito user pool and associated resources.
# terraform/modules/cognito/main.tf
# This Terraform configuration defines the following AWS Cognito resources:
# 1. Cognito User Pool (aws_cognito_user_pool): Creates a user pool with specified settings including account recovery, verification message template, auto-verified attributes, and password policy.
# 2. Cognito User Pool Domain (aws_cognito_user_pool_domain): Creates a domain for the user pool.
# 3. Cognito User Pool Client (aws_cognito_user_pool_client): Creates a user pool client with specified authentication flows and token validity settings.
resource "aws_cognito_user_pool" "user_pool" {
name = var.cognito_user_pool_name
account_recovery_setting {
recovery_mechanism {
name = "verified_email"
priority = 1
}
}
verification_message_template {
default_email_option = "CONFIRM_WITH_LINK"
}
auto_verified_attributes = ["email"]
password_policy {
minimum_length = 8
require_uppercase = true
require_lowercase = true
require_numbers = true
require_symbols = true
}
}
resource "aws_cognito_user_pool_domain" "user_pool_domain" {
domain = var.cognito_user_pool_domain
user_pool_id = aws_cognito_user_pool.user_pool.id
}
resource "aws_cognito_user_pool_client" "user_pool_client" {
name = var.cognito_app_client_name
user_pool_id = aws_cognito_user_pool.user_pool.id
explicit_auth_flows = [
"ALLOW_USER_PASSWORD_AUTH",
"ALLOW_REFRESH_TOKEN_AUTH"
]
generate_secret = false
access_token_validity = 60
id_token_validity = 60
refresh_token_validity = 7
token_validity_units {
access_token = "minutes"
id_token = "minutes"
refresh_token = "days"
}
}
outputs.tf
: Exports the Cognito user pool ID and client ID. These values will be used across modules to integrate the Cognito authorizer with the API Gateway and to provide them as environment variables for Lambda functions.
# terraform/modules/cognito/outputs.tf
# This Terraform configuration defines the following outputs of the cognito module:
# 1. user_pool_id: The ID of the created Cognito User Pool.
# 2. user_pool_client_id: The ID of the created Cognito User Pool Client.
output "user_pool_id" {
value = aws_cognito_user_pool.user_pool.id
}
output "user_pool_client_id" {
value = aws_cognito_user_pool_client.user_pool_client.id
}
variables.tf
: Define required input variables.
# terraform/modules/cognito/variables.tf
# This Terraform configuration defines the following input variables of the cognito module:
# 1. aws_region: The AWS region to deploy resources.
# 2. cognito_user_pool_name: The name of the Cognito User Pool.
# 3. cognito_app_client_name: The name of the Cognito App Client.
# 4. cognito_user_pool_domain: The domain name for the Cognito user pool.
variable "aws_region" {
description = "The AWS region to deploy resources"
type = string
}
variable "cognito_user_pool_name" {
description = "The name of the Cognito User Pool"
type = string
}
variable "cognito_app_client_name" {
description = "The name of the Cognito App Client"
type = string
}
variable "cognito_user_pool_domain" {
description = "The domain name for the Cognito user pool"
type = string
}
Integrating Cognito Authorizer with API Gateway
We need to update the API Gateway configuration to use the Cognito authorizer for securing the endpoints. The authorizer will validate incoming requests using the JWT token provided in the Authorization header.
- Update the API Gateway module in the Terraform configuration to include the Cognito authorizer.
# terraform/modules/apigateway/main.tf
resource "aws_apigatewayv2_authorizer" "cognito_authorizer" {
api_id = aws_apigatewayv2_api.api_gateway.id
authorizer_type = "JWT"
identity_sources = ["$request.header.Authorization"]
name = "cognito_authorizer"
jwt_configuration {
audience = [var.user_pool_client_id]
issuer = "https://cognito-idp.${var.aws_region}.amazonaws.com/${var.user_pool_id}"
}
}
- Update the API Gateway module to export the Cognito authorizer ID.
# terraform/modules/apigateway/outputs.tf
output "cognito_authorizer_id" {
value = aws_apigatewayv2_authorizer.cognito_authorizer.id
}
- Update apigateway module variables to include the Cognito user pool id and user pool client ID.
# terraform/modules/apigateway/variables.tf
variable "user_pool_id" {
type = string
}
variable "user_pool_client_id" {
type = string
}
We’ll update the existing Lambda functions to validate incoming requests using the JWT token provided in the Authorization header. The API Gateway will be configured to use the Cognito authorizer for securing the endpoints.
Modify:
102/terraform/modules/lambda-api/lambda-handler-create-epic-failure.tf
102/terraform/modules/lambda-api/lambda-handler-delete-epic-failure.tf
102/terraform/modules/lambda-api/lambda-handler-get-all-failures.tf
Add the following snippet at the end of each aws_apigatewayv2_route
block:
authorization_type = "JWT"
authorizer_id = var.cognito_authorizer_id
Update Lambda API variables
We need to add the following variables to the Lambda API module. First two will be used in the Cognito Service we are going to impement, and the last one will is referenced in the API Gateway configuration.
variable "user_pool_id" {
type = string
}
variable "user_pool_client_id" {
type = string
}
variable "cognito_authorizer_id" {
type = string
}
Extend lambda api shared environment variables
In the terraform/lambda-api.tf
file, add the following to the shared environment variables:
locals {
shared_env_vars = {
USER_POOL_ID = var.user_pool_id
COGNITO_CLIENT_ID = var.user_pool_client_id
DYNAMODB_TABLE_NAME = var.dynamo_table_name
}
}
IAM Policies for Cognito Access
We will create new Lambda execution roles to include permissions for interacting with Cognito in sign-up and sign-in handlers. Let's extend terraform/modules/lambda-api/main.tf
with the following resources:
# IAM Role for Lambda execution that needs more permissions (sign up and sign in)
resource "aws_iam_role" "lambda_cognito_admin_role" {
name = "lambda_cognito_admin_role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "lambda_cognito_admin_role_policy" {
role = aws_iam_role.lambda_cognito_admin_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy" "lambda_cognito_admin_role_policy" {
name = "lambda_cognito_admin_role_policy"
role = aws_iam_role.lambda_cognito_admin_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"cognito-idp:SignUp",
"cognito-idp:AdminGetUser",
"cognito-idp:AdminInitiateAuth"
]
Effect = "Allow"
Resource = [
"*"
]
}
]
})
}
resource "aws_iam_role_policy_attachment" "lambda_cognito_admin_dynamodb_policy_attachment" {
role = aws_iam_role.lambda_cognito_admin_role.name
policy_arn = aws_iam_policy.lambda_dynamodb_policy.arn
}
New methods created in node.js application will require those permissions to interact with Cognito.
Updating root main.tf and variables.tf files
The final modification for Terraform is updating the root main.tf
file to include the Cognito module and to provide existing modules with additional shared variables.
# terraform/main.tf
# This root Terraform configuration defines the following AWS resources and modules:
# 1. AWS Provider (provider "aws"): Configures the AWS provider with the specified region and profile.
# 2. API Gateway Module (module "apigateway"): Deploys an API Gateway with the specified settings and integrates with Cognito.
# 3. Lambda API Module (module "lambda-api"): Deploys Lambda functions for API operations, integrates with API Gateway, Cognito, and DynamoDB.
# 4. DynamoDB Module (module "dynamodb"): Creates a DynamoDB table with the specified settings.
# 5. Cognito Module (module "cognito"): Creates a Cognito User Pool, User Pool Client, and User Pool Domain with the specified settings.
provider "aws" {
region = "eu-central-1"
profile = "move2edge-dev"
}
module "apigateway" {
source = "./modules/apigateway"
aws_region = var.aws_region
api_gateway_name = var.api_gateway_name
api_gateway_stage_name = var.api_gateway_stage_name
user_pool_id = module.cognito.user_pool_id
user_pool_client_id = module.cognito.user_pool_client_id
}
module "lambda-api" {
source = "./modules/lambda-api"
aws_region = var.aws_region
dynamo_table_name = var.dynamo_table_name
api_gateway_id = module.apigateway.api_gateway_id
api_gateway_execution_arn = module.apigateway.api_gateway_execution_arn
cognito_authorizer_id = module.apigateway.cognito_authorizer_id
user_pool_client_id = module.cognito.user_pool_client_id
user_pool_id = module.cognito.user_pool_id
}
module "dynamodb" {
source = "./modules/dynamodb"
aws_region = var.aws_region
dynamo_table_name = var.dynamo_table_name
}
module "cognito" {
source = "./modules/cognito"
aws_region = var.aws_region
cognito_user_pool_name = var.cognito_user_pool_name
cognito_app_client_name = var.cognito_app_client_name
cognito_user_pool_domain = var.cognito_user_pool_domain
}
# terraform/variables.tf
# This Terraform configuration defines the following input variables:
# 1. aws_region: The AWS region to deploy resources.
# 2. api_gateway_name: The name of the API Gateway.
# 3. api_gateway_stage_name: The name of the API Gateway Stage.
# 4. dynamo_table_name: The name of the DynamoDB table.
# 5. cognito_user_pool_name: The name of the Cognito User Pool.
# 6. cognito_app_client_name: The name of the Cognito App Client.
# 7. cognito_user_pool_domain: The domain name for the Cognito user pool.
variable "aws_region" {
description = "The AWS region to deploy resources"
default = "eu-central-1"
}
variable "api_gateway_name" {
description = "The name of the API Gateway"
default = "epic-failures-apigateway"
}
variable "api_gateway_stage_name" {
description = "The name of the API Gateway Stage"
default = "dev"
}
variable "dynamo_table_name" {
description = "The name of the DynamoDB table"
default = "epic-failures"
}
variable "cognito_user_pool_name" {
description = "The name of the Cognito User Pool"
default = "epic-failures-user-pool"
}
variable "cognito_app_client_name" {
description = "The name of the Cognito App Client"
default = "epic-failures-app_client"
}
variable "cognito_user_pool_domain" {
description = "The domain name for the Cognito user pool"
type = string
default = "epic-failures"
}
Adding Sign-Up and Sign-In Endpoints
Extending the Node.js Application
To interact with Cognito, install the necessary AWS SDK packages:
yarn add @aws-sdk/client-cognito-identity-provider
Implementing CognitoService
Let's create a new service class that will handle user sign-up and sign-in operations using the AWS SDK.
// epicfailure-api/src/services/ICognitoService.ts
import { ILoginData } from "src/models/LoginData";
// This file defines the ICognitoService interface, which specifies the methods for interacting with AWS Cognito.
export interface ICognitoService {
signUp(email: string, password: string, name: string): Promise<void>;
signIn(email: string, password: string): Promise<ILoginData>;
}
// epicfailure-api/src/services/CognitoService.ts
// This file defines the CognitoService class, which implements the ICognitoService interface.
// The class provides methods for interacting with AWS Cognito, including user sign-up and sign-in.
// It uses the AWS SDK for JavaScript to communicate with the Cognito Identity Provider.
import { CognitoIdentityServiceProvider } from 'aws-sdk';
import {
CognitoIdentityProviderClient,
InitiateAuthCommand,
InitiateAuthCommandInput,
SignUpCommand,
AdminGetUserCommand,
} from '@aws-sdk/client-cognito-identity-provider';
import { ICognitoService } from './ICognitoService';
import { ILoginData } from 'src/models/LoginData';
class CognitoService implements ICognitoService {
private client: CognitoIdentityProviderClient;
private userPoolId: string;
private clientId: string;
constructor() {
this.client = new CognitoIdentityProviderClient({ region: process.env.AWS_REGION });
this.userPoolId = process.env.USER_POOL_ID!;
this.clientId = process.env.COGNITO_CLIENT_ID!;
}
async signUp(email: string, password: string, name: string): Promise<void> {
const params: CognitoIdentityServiceProvider.SignUpRequest = {
ClientId: this.clientId,
Username: email,
Password: password,
UserAttributes: [
{
Name: 'email',
Value: email,
},
{
Name: 'name',
Value: name,
},
],
};
try {
const command = new SignUpCommand(params);
const response = await this.client.send(command);
console.log('Sign up successful:', response);
} catch (error) {
console.error('Error signing up:', error);
throw error;
}
}
async signIn(email: string, password: string): Promise<ILoginData> {
const params: InitiateAuthCommandInput = {
AuthFlow: 'USER_PASSWORD_AUTH',
ClientId: this.clientId,
AuthParameters: {
USERNAME: email,
PASSWORD: password,
},
};
try {
const command = new InitiateAuthCommand(params);
const response = await this.client.send(command);
console.log('Sign in successful:', response);
// Fetch user attributes
const userParams = {
UserPoolId: this.userPoolId,
Username: email,
};
const adminGetUserCommand = new AdminGetUserCommand(userParams);
const userResponse = await this.client.send(adminGetUserCommand);
console.log('User attributes fetched:', userResponse);
return {
email,
name: userResponse.UserAttributes?.find((attr) => attr.Name === 'name')?.Value || '',
idToken: response.AuthenticationResult?.IdToken || '',
};
} catch (error) {
console.error('Error signing in:', error);
throw error;
}
}
}
export default CognitoService;
To handle this code we are missing LoginData model. Let's create it.
// epicfailure-api/src/models/LoginData.ts
// This file defines the LoginData class, which represents the data returned after a successful user login.
export interface ILoginData {
idToken: string;
name: string;
email: string;
}
export default class LoginData implements ILoginData {
idToken: string;
name: string;
email: string;
}
Create the Sign-Up Handler
Define a new handler for user registration. The implementation will:
- Accept user credentials (email, password) from the request body.
- Use Cognito’s SignUp API to register the user.
Let's expose the handler as a Lambda function in the Terraform configuration.
# # terraform/modules/lambda-api/lambda-handler-signup.tf
# This Terraform configuration defines the following AWS resources:
# 1. Lambda Function for Sign-Up (aws_lambda_function): Creates a Lambda function for handling user sign-ups.
# 2. Data Source for Sign-Up Lambda Code (data "archive_file"): Archives the sign-up Lambda function code into a zip file.
# 3. API Gateway Integration for Sign-Up Lambda (aws_apigatewayv2_integration): Creates an integration between API Gateway and the sign-up Lambda function.
# 4. API Gateway Route for Sign-Up (aws_apigatewayv2_route): Creates a route in API Gateway for the sign-up endpoint.
# 5. Lambda Permission for API Gateway (aws_lambda_permission): Grants API Gateway permission to invoke the sign-up Lambda function.
resource "aws_lambda_function" "lambda_sign_up" {
function_name = "${var.lambda_function_name_prefix}-sign-up"
runtime = "nodejs18.x"
handler = "sign-up-handler.handler"
role = aws_iam_role.lambda_cognito_admin_role.arn
filename = data.archive_file.sign_up_lambda_zip.output_path
source_code_hash = data.archive_file.sign_up_lambda_zip.output_base64sha256
environment {
variables = local.shared_env_vars
}
}
data "archive_file" "sign_up_lambda_zip" {
type = "zip"
source_file = "${path.module}/../../../epicfailure-api/dist/handlers/sign-up-handler.js"
output_path = "${path.module}/../../../epicfailure-api/dist/signUp.zip"
}
resource "aws_apigatewayv2_integration" "lambda_sign_up" {
api_id = var.api_gateway_id
integration_uri = aws_lambda_function.lambda_sign_up.invoke_arn
integration_type = "AWS_PROXY"
integration_method = "POST"
}
resource "aws_apigatewayv2_route" "post_sign_up" {
api_id = var.api_gateway_id
route_key = "POST /sign-up"
target = "integrations/${aws_apigatewayv2_integration.lambda_sign_up.id}"
}
resource "aws_lambda_permission" "api_gw_sign_up" {
statement_id = "AllowExecutionFromAPIGateway"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.lambda_sign_up.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${var.api_gateway_execution_arn}/*/*"
}
Create the Sign-In Handler
The sign-in handler will:
Accept user credentials. Use Cognito’s InitiateAuth API to authenticate and return a JWT token.
// epicfailure-api/src/handlers/sign-in/sign-in-handler.ts
// This file defines the AWS Lambda handler for user sign-in using Cognito.
// It imports necessary services and libraries, validates the incoming request,
// and uses CognitoService to authenticate the user with the provided email and password.
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import * as Joi from 'joi';
import CognitoService from 'src/services/CognitoService';
const cognitoService = new CognitoService();
const signInSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().required(),
});
const signInHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
const { email, password } = JSON.parse(event.body || '{}');
const { error } = signInSchema.validate({ email, password });
if (error) {
return {
statusCode: 400,
body: JSON.stringify({ message: 'Invalid request body', error: error.details[0].message }),
};
}
try {
const loginData = await cognitoService.signIn(email, password);
return {
statusCode: 200,
body: JSON.stringify(loginData),
};
} catch (error) {
console.log(error);
return {
statusCode: 401,
body: JSON.stringify({ message: 'Unauthorized' }),
};
}
};
exports.handler = signInHandler;
Create analogous Terraform resources for the sign-in handler:
# terraform/modules/lambda-api/lambda-handler-signin.tf
# This Terraform configuration defines the following AWS resources:
# 1. Lambda Function for Sign-In (aws_lambda_function): Creates a Lambda function for handling user sign-ins.
# 2. Data Source for Sign-In Lambda Code (data "archive_file"): Archives the sign-in Lambda function code into a zip file.
# 3. API Gateway Integration for Sign-In Lambda (aws_apigatewayv2_integration): Creates an integration between API Gateway and the sign-in Lambda function.
# 4. API Gateway Route for Sign-In (aws_apigatewayv2_route): Creates a route in API Gateway for the sign-in endpoint.
# 5. Lambda Permission for API Gateway (aws_lambda_permission): Grants API Gateway permission to invoke the sign-in Lambda function.
resource "aws_lambda_function" "lambda_sign_in" {
function_name = "${var.lambda_function_name_prefix}-sign-in"
runtime = "nodejs18.x"
handler = "sign-in-handler.handler"
role = aws_iam_role.lambda_cognito_admin_role.arn
filename = data.archive_file.sign_in_lambda_zip.output_path
source_code_hash = data.archive_file.sign_in_lambda_zip.output_base64sha256
environment {
variables = local.shared_env_vars
}
}
data "archive_file" "sign_in_lambda_zip" {
type = "zip"
source_file = "${path.module}/../../../epicfailure-api/dist/handlers/sign-in-handler.js"
output_path = "${path.module}/../../../epicfailure-api/dist/signIn.zip"
}
resource "aws_apigatewayv2_integration" "lambda_sign_in" {
api_id = var.api_gateway_id
integration_uri = aws_lambda_function.lambda_sign_in.invoke_arn
integration_type = "AWS_PROXY"
integration_method = "POST"
}
resource "aws_apigatewayv2_route" "post_sign_in" {
api_id = var.api_gateway_id
route_key = "POST /sign-in"
target = "integrations/${aws_apigatewayv2_integration.lambda_sign_in.id}"
}
resource "aws_lambda_permission" "api_gw_sign_in" {
statement_id = "AllowExecutionFromAPIGateway"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.lambda_sign_in.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${var.api_gateway_execution_arn}/*/*"
}
Deploying and Testing
Once the configurations are in place, build and deploy the updated code and infrastructure. You can use single yarn script that combines bundling handlers' code script and the terraform apply
command.
cd epicfailure-api && yarn build-and-deploy
In the terminal export the INVOKE_URL from the build and deploy script output.
export INVOKE_URL="https://<api-id>.execute-api.<region>.amazonaws.com/<stage>"
Create an epic failure records
Use the curl POST request to create epic failure record the same way we did in the previous 102
part.
curl -X POST \
${INVOKE_URL}/epic-failures \
-H "Content-Type: application/json" \
-d '{
"failureID": "001",
"taskAttempted": "deploying a feature on Friday at 4:59 p.m.",
"whyItFailed": "triggered a cascade of unexpected errors",
"lessonsLearned": ["never deploy on a Friday afternoon"]
}'
Since we have secured the API with Cognito, the request will be unauthorized. Let’s sign up and sign in to get the JWT token.
Sign up
Export the email address for the sign-up request.
export EMAIL="your-email@example.com"
curl -X POST \
${INVOKE_URL}/sign-up \
-H "Content-Type: application/json" \
-d '{
"email": "'"${EMAIL}"'",
"password": "Password123!",
"name": "Test User"
}'
Now you need to open your inbox and accept the invitation to sign up. This is a standard Cognito flow to verify the email address. This step is crucial to be able to sign in.
Now let's open AWS Cognito console and check if the user has been created.
Sign in and export idToken
OK, now we have an user created in Cognito. Let's sign in and export the idToken
. It will be used in following requests.
export ID_TOKEN=$(curl -s -X POST \
${INVOKE_URL}/sign-in \
-H "Content-Type: application/json" \
-d '{
"email": "'"${EMAIL}"'",
"password": "Password123!"
}' | jq -r '.idToken')
If your email address has not been verified, Cognito will not return an idToken
. In this case, the ID_TOKEN
variable will be set to null
. To resolve this, verify your email address and sign in again.
Create Epic Failure once again
curl -X POST \
${INVOKE_URL}/epic-failures \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ID_TOKEN}" \
-d '{
"failureID": "001",
"taskAttempted": "deploying a feature on Friday at 4:59 p.m.",
"whyItFailed": "triggered a cascade of unexpected errors",
"lessonsLearned": ["never deploy on a Friday afternoon"]
}'
Well done! You have successfully secured your API with Cognito and implemented user sign-up and sign-in endpoints. The JWT token ensures that unauthorized users cannot gain access.
Wrapping Up
At this stage, your API is secure, with endpoints protected by Cognito authentication. Users can register and log in, and JWT tokens ensure secure communication. All Terraform code is organized to allow straightforward updates, making this project ready for future enhancements.
The final source code for this part is available in the GitHub repository in the 103
directory. Be sure to follow along for the full implementation details and deploy it in your environment.
In the next, last part 104
we will optimize the deployment by introducing Lambda Layers. Stay tuned!