Integrate API Gateway with Cognito and Lambda

AWS API Gateway integrated with Cognito is perfect approach for exposing Lambda or other computing services. It offers a secure and scalable solution also for serverless applications. In this blog post, we will explore how to integrate API Gateway with Cognito Authorizer and Lambda, ensuring only authentication users can invoke you API endpoints. Additionally we will deploy it with Terraform so that it is easy to add new integrations and connect new Lambda functions to same API Gateway. The source code for the Lambda function that grabs data from a storage service is out of scope for this blog post.

Setting Up the API Gateway and Lambda Integration

In this example, we create an HTTP API Gateway with Lambda integrations and a Cognito JWT authorizer. Nevertheless REST API Gateway can also be deployed but for the sake of the simplicity we will deploy HTTP type since we do not need advanced features REST type offers.

We will focus on a simplified version, integrating two basic routes with their corresponding Lambda functions. These routes will be /items for retrieving data and /items/create for adding new items.

Step 1: Define the API Gateway
resource "aws_apigatewayv2_api" "api" {
  name          = "item-api"
  protocol_type = "HTTP"
  
  #not needed for machine to machine
  cors_configuration {
    allow_origins     = ["*"] #could be CloudFront domain name
    allow_methods     = ["GET", "POST"]
    allow_headers     = ["Content-Type", "Authorization"]
    max_age           = 3600
  }
}

resource "aws_apigatewayv2_stage" "default" {
  api_id      = aws_apigatewayv2_api.api.id
  name        = "$default"
  auto_deploy = true
}

This defines an HTTP API Gateway that allows both GET and POST methods for routes, with CORS configuration to allow calls from any origin. The Authorization header is set to be accepted for JWT tokens used by Cognito.

The stage resource defines the deployment stage for the API Gateway and with these default settings we ensure that API Gateway is automatically deployed to the default stage whenever there are changes. Of course for more complex setups it would be configured different.

NOTE: Cors configuration is needed for client to machine communication – for example S3 + CloudFront hosted frontend. However for machine to machine cors is not needed.

Step 2: Define the Lambda Functions

Now, we define two Lambda functions that will handle the requests for our routes.

resource "aws_lambda_function" "get_items_lambda" {
  function_name    = var.get_items_lambda.name
  handler          = "get-items.handler"
  runtime          = var.get_items_lambda.runtime
  role             = aws_iam_role.lambda_exec.arn
  filename         = "lambda/get-items.zip"
  source_code_hash = filebase64sha256("lambda/get-items.zip")
  memory_size      = var.get_items_lambda.memory_size
  timeout          = var.get_items_lambda.timeout
  vpc_config {
    subnet_ids         = [aws_subnet.private_1.id, aws_subnet.private_2.id]
    security_group_ids = [aws_security_group.lambda_sg.id]
  }
}

variable "get_items_lambda" {
  description = "Retrieves items"
  type = object({
    name        = string
    runtime     = string
    memory_size = number
    timeout     = number
  })
  default = {
    name        = "get-items"
    runtime     = "python3.12"
    memory_size = 256
    timeout     = 60
  }
}

resource "aws_lambda_function" "create_item_lambda" {
  function_name    = var.create_item_lambda.name
  handler          = "create-item.handler"
  runtime          = var.create_item_lambda.runtime
  role             = aws_iam_role.lambda_exec.arn
  filename         = "lambda/create-item.zip"
  source_code_hash = filebase64sha256("lambda/create-item.zip")
  memory_size      = var.create_item_lambda.memory_size
  timeout          = var.create_item_lambda.timeout
  vpc_config {
    subnet_ids         = [aws_subnet.private_1.id, aws_subnet.private_2.id]
    security_group_ids = [aws_security_group.lambda_sg.id]
  }
}

variable "create_item_lambda" {
  description = "Creates a new item"
  type = object({
    name        = string
    runtime     = string
    memory_size = number
    timeout     = number
  })
  default = {
    name        = "create-item"
    runtime     = "python3.12"
    memory_size = 256
    timeout     = 60
  }
}

These Lambda functions will retrieve and create items respectively. You will need to upload your Lambda deployment packages (get-items.zip and create-item.zip) to be used by AWS Lambda. This setup with variable of type object is just my preference of providing values to Lambda resource but feel free to configure this as you wish.

Step 3: Set Up Cognito for Authentication

To ensure that only authenticated users can access the API, we set up a Cognito User Pool and configure a JWT authorizer for API Gateway.

resource "aws_cognito_user_pool" "user_pool" {
  name = "item-api-user-pool"
}

resource "aws_cognito_user_pool_client" "user_pool_client" {
  name            = "item-api-client"
  user_pool_id    = aws_cognito_user_pool.user_pool.id
  generate_secret = true
  allowed_oauth_flows_user_pool_client = true
  allowed_oauth_flows                  = ["client_credentials"]
}

resource "aws_apigatewayv2_authorizer" "cognito_authorizer" {
  name             = "CognitoJWTAuthorizer"
  api_id           = aws_apigatewayv2_api.api.id
  authorizer_type  = "JWT"
  identity_sources = ["$request.header.Authorization"]

  jwt_configuration {
    audience = [aws_cognito_user_pool_client.user_pool_client.id]
    issuer   = "https://${aws_cognito_user_pool.user_pool.endpoint}"
  }
}

This configuration creates a Cognito User Pool and a client, as well as a JWT Authorizer for API Gateway. The Authorization header from incoming requests will be checked against the Cognito token to authenticate the user. In simple words what is going on here is that we need like create pool which we can envision like a database where users are stored. Next we need a client that allows external applications to authenticate users against user pool we created. This client will generate Client ID and Client Secret that we will use to send requests to our API Gateway endpoint. Finally we need to configure Cognito Authorizer and this is set up in API Gateway so that only users with valid JWT token can access our API. The token is checked using the Authorization header in request and it verifies the user’s identity against the Cognito user pool.
Note that we have allowed OAuth flow for client_credentials which is needed for machine to machine communication because it enables the server or app to authentication directly using credentials.

Step 4: Configure API Gateway Routes with Lambda Integration

We now define the routes in the API Gateway and link them to the Lambda functions. The Cognito authorizer is applied to each route to secure them.

First we can create locals.tf to be easier to manage routes and add new paths when needed:

locals {
  lambda_routes = {
    get_items_lambda = {
      method      = "GET"
      path        = "/items"
      lambda      = aws_lambda_function.get_items_lambda
      name_suffix = "get_items"
    },
    create_item_lambda = {
      method      = "POST"
      path        = "/items/create"
      lambda      = aws_lambda_function.create_item_lambda
      name_suffix = "create_item"
    }
  }
}

Now we need to integrate API Gateway with both lambdas that is why we use for_each for local lambda_routes and we use POST which specifies that API Gateway will use that method to invoke Lambda function. each.value.lambda.invoke_arn refers to the ARN of the Lambda function to be invoked dynamically pulled from locals.

resource "aws_apigatewayv2_integration" "lambda_integrations" {
  for_each               = local.lambda_routes
  api_id                 = aws_apigatewayv2_api.api.id
  integration_type       = "AWS_PROXY"
  integration_uri        = each.value.lambda.invoke_arn
  integration_method     = "POST"
  payload_format_version = "2.0"
}

API Gateway route resource defines the routes in API Gateway that map to the Lambda functions. The route_key is combination of the HTTP method and path (e.g., GET /items). The target points to the Lambda integration and of course we need authorizer_id to link the route to the Cognito authorizer ensuring only authenticated requests.

resource "aws_apigatewayv2_route" "lambda_routes" {
  for_each           = local.lambda_routes
  api_id             = aws_apigatewayv2_api.api.id
  route_key          = "${each.value.method} ${each.value.path}"
  target             = "integrations/${aws_apigatewayv2_integration.lambda_integrations[each.key].id}"
  authorizer_id      = aws_apigatewayv2_authorizer.cognito_authorizer.id
  authorization_type = "JWT"
}

At the end we need to grant permission to API Gateway to invoke Lambda functions. For each route we need a unique statement_id and name of the function is also dynamically pulled from locals. The source_arn ensures that only requests from specific API Gateway are allowed to trigger Lambda.

resource "aws_lambda_permission" "lambda_permissions" {
  for_each      = local.lambda_routes
  statement_id  = "AllowExecutionFromAPIGateway_${each.value.name_suffix}"
  action        = "lambda:InvokeFunction"
  function_name = each.value.lambda.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}

Each route uses the JWT authorizer, ensuring that requests are only allowed from authenticated users. The routes map to the GET /items and POST /items endpoints, which correspond to the Lambda functions defined earlier.

Step 5: Test the API

Once the resources are deployed, you can test the API.

This would be example of request to get the token:

curl -X POST https://<your-cognito-domain>.auth<region>.amazoncognito.com/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=<your-client-id>" \
  -d "client_secret=<your-client-secret>" \
  -d "scope=<your-api-scope>"

The response would be:

{
  "access_token": "eyJraWQiOi...",
  "token_type": "bearer",
  "expires_in": 3600
}

After we have token we can send request to trigger lambda function:

curl -X GET https://<api-id>.execute-api.<region>.amazonaws.com/items \
  -H "Authorization: Bearer <access_token>"
  
  
curl -X GET https://<api-id>.execute-api.<region>.amazonaws.com/items/create\
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"name": "New Item", "description": "Item description"}'

Conclusion

In this post, we walked through integrating API Gateway with Cognito and Lambda to set up a secure serverless API. By using Cognito’s JWT authentication with API Gateway, we ensure that only authorized users can access specific API routes. This integration allows you to create secure, scalable serverless applications without the need to manage infrastructure.

If this blog saved you time, support me with a coffee!

Thanks to everyone who’s supported!