Cyber attacks against APIs (Application Programming Interfaces) are on the increase. These attacks arise from issues with proper authentication, authorization, unnecessary data exposure, lack of request limits, resource consumption, and use of vulnerable third-party APIs.

Gaps in APIs can occur before requests reach the APIs, within the code housing the APIs, and even along the path of the APIs’ communication with downstream services, dependencies, or other microservices.

Attackers leverage flaws in APIs to gain access to confidential data, harvest or manipulate data, or even make your service unavailable through distributed denial of service attacks.

In this article, you’ll learn to deploy your APIs in Lambda and apply some security measures pre-function, within the function, and post-function.

Table of Contents

What is an API?

The focus of this article is the security of Application Programming Interfaces (APIs). An API is an interface that connects two programms or applications, allowing them to exchange data and communicate.

An API can be internal to an organization or it can belong to a third-party that allows other users to consume their data through the API.

Requirements/Prerequisites

While this tutorial is beginner-friendly, you’ll need the following prerequisites to follow along seamlessly:

  • A basic knowledge of the AWS Cloud.

  • An AWS account with administrator access.

  • AWS CLI. You can find the installation guide here. Follow the instructions for your operating system.

  • Python. You can visit Python’s official documentation site for a guide on how to download and install Python for your specific operating system.

  • Pipenv or any Python virtual environment creation tool. You can find the Pipenv installation guide here.

  • A basic knowledge of Git.

  • An API client, like Postman or Thunderclient.

Project Goal

By the end of this project, you should be able to deploy APIs in Lambda securely, leveraging AWS cloud-native security services.

Project Overall Architecture

Below is the architecture of the project workflow:

Project Architectural Diagram

As shown in the architectural diagram, when a user sends a request (a JSON object consisting of the user’s name) to an API hosted in Lambda, the user first gets authenticated by an authentication service called Amazon Cognito.

The request passes through a Web Application Firewall, then an API Gateway. API Gateway will perform a check to see if the user is authorized to access the API using the token that the user sends with the request after authentication. API Gateway then allows the traffic to pass through to the API if the user is authorized.

The user’s request will first get to an External Lambda function, which will then save the user’s name as a message to a Simple Notification Service (SNS) topic. This will then invoke an Internal Lambda to run and log the output in Amazon CloudWatch logs. The SNS topic will be accessed by External Lambda using the SNS’s unique identifier stored in Amazon Secrets Manager.

AWS Set Up

You’ll need to set up an AWS environment to get started. This requires creating an account if you don’t already have one.

Following account creation, a root user is automatically created, with all privileges attached to the user. Security best practice is to create another user with administrator privileges and use this user for subsequent tasks.

Then, create an access key for this user, which usually consists of two parts (Access Key ID and Secret Access Key) by navigating to the following:

IAM —→ Users —→ Create Access key

Follow the prompts and choose the Command Line Interface option. Check the Confirmation box, and go on to create the key. Download the CSV file provided, or manually copy the Access Key ID and Secret Access Key. Save them securely.

IAM Dashboard

IAM User Page

Create Access Key Page

Access Key Use Option Page

Set Access Key Tag Page

Download Access Key

Open up your terminal and run the following commands using the AWS CLI:

aws configure

The above command will give some prompts to provide the components of the Access Key created earlier and your default region (the AWS region hosting the service you intend to interact with).

Clone Project

In the next step, you’ll clone the GitHub repository containing the assets and resources used in the project implementation.

Visit the project URL and clone the repository locally.

git clone <repository_clone_url>

Set Up Simple Notification Service

Amazon Simple Notification Service (SNS) connects system components, enabling asynchronous communication and messaging among them.

Find SNS on the console, click on it, and create a topic that your APIs will send messages to. After successfully creating a topic, navigate to the topic, and in the topic details, you’ll find the topic’s ARN. An ARN is an Amazon Resource Name, and it’s a unique string attached to a resource you’ve created on AWS to help identify the resource. Copy the ARN of the topic.

SNS Dashboard

Create SNS Topic

Topic Details

SNS Topic Access Policy

Topic Created

Set Up Secrets Manager

Amazon Secrets Manager is used to store, manage, and retrieve sensitive information such as keys, credentials, tokens, and so on. You’ll store the Topic ARN created earlier. With this approach, you’ll demonstrate how your API can securely access the data and information it needs for its performance.

Go to Secrets Manager on the AWS console and create a secret. Provide the secret’s details, and add a new secret named TOPIC_ARN as the key and the actual SNS Topic ARN as the value.

Secrets Manager Console

Create Secret

Choose Other Types of Secret

Secret Details

Final Secret Store

Next, you’ll create some Lambda functions to serve your APIs and consume the output of the APIs. There’re three Lambda functions to set up. Two of the functions will host APIs, each of which can only be accessed by specific users. These will be referred to as ExternalLambda. The third Lambda will consume the output of the External Lambda functions through SNS.

Set Up Internal Lambda

AWS Lambda is a serverless service on AWS that users can leverage to run application functions or code when needed. You’re billed for your Lambda function based on the number of invocations of the function, the duration each invocation lasted, and the amount of memory allocated to the function. Lambda can be provisioned to use any runtime, such as Python or NodeJS. In this demonstration, you’ll focus on the NodeJS runtime.

Now that you know what Lambda is and does, you can create one. Let’s call the first Lambda function InternalLambda. On the AWS console, search for Lambda, and on the Lambda dashboard, click Create a function and provide the details. We’ll be using Node.js – JavaScript at the backend as the runtime of choice.

AWS Lambda

Lambda Details

For the Permissions details, let Lambda create a default IAM Role. This default role is named according to your function, and the permissions attached to the role allow your Lambda function to send logs to CloudWatch, another AWS service used for monitoring and observability.

Lambda Permissions

39d50020-d0bf-4cfe-950f-eec8a2ff8989

As you can see in the last image above, the Lambda function you’ve created needs a trigger and sometimes, a destination. For your InternalLambda, the trigger is the SNS topic we configured earlier. This Lambda will read the messages that’ve been published to it, and then you can access the message from your client or even CloudWatch logs.

To achieve this, click the Add trigger button and provide the details.

Add SNS to Lambda

SNS ARN

InternalLambda Overview

Next, you’ll provide the code you want to invoke through Lambda. Find the code in the GitHub repository that you cloned earlier. Paste the code in the Lambda function code space and click on Deploy to deploy the function.

secure-lambda/InternalLambda/index.js

export const handler = async (event) => {
    try {
        console.log('Request successfully received from SNS');                            

        let name = event['Records'][0]['Sns']['Message'];
        let response = {
            statusCode: 200,
            body: JSON.stringify(`Hello ${name}. Greetings from InternalLambda!`),
        };       
        console.log('Response: ', response);                                                
        return response;
    } catch (err) {                            
        let response = {
            statusCode: 500,
            body: JSON.stringify('An error occurred while processing your request.'),
        };

        console.error('Error processing event', err);
        return response;
    }   
};

The function defined in the index.js file above is simply taking the event object sent to it from SNS and extracting the Message attribute within it. We’re using console.log here to view outputs from the function and ensure it behaves as expected. Just don’t use this in a production-ready application.

InternalLambda Code

Set Up External Lambda

You’ll be creating two external Lambda functions: 1 and 2. These two functions will receive user requests, process them, and publish messages to your SNS topic.

On the Lambda console, create another function and name it ExternalLambda1. Allow Lambda to create a default IAM Role, as previously.

Create ExternalLambda1

ExternalLambda1 Overview

Paste the code snippet below in the ExternalLambda1 code space:

secure-lambda/ExternalLambda1/insex.js

import {
  GetSecretValueCommand,
  SecretsManagerClient,
} from "@aws-sdk/client-secrets-manager";

import { SNSClient, 
    PublishCommand 
} from "@aws-sdk/client-sns";

const secretsManagerClient = new SecretsManagerClient();

const snsClient = new SNSClient({});

// Fetch topicArn from AWS Secrets Manager
async function getSecretValue(secretName) {
    try {
        const data = await secretsManagerClient.send(
                            new GetSecretValueCommand({
                            SecretId: secretName,
                            }),
                        );
        if (data.SecretString) {
            return JSON.parse(data.SecretString);
        }   else {
            let buff = Buffer.from(data.SecretBinary, 'base64');
            return JSON.parse(buff.toString("utf-8"));
        }
    } catch (err) {
        console.error('Error retrieving secret', err);                             // added for debugging
        throw err;
    }
}                                        

export const handler = async (event) => {

    let name = event['name'];
    console.log(`Request successfully received from ${name}`);    

    // Retrieve SNS Topic ARN from Secrets Manager
    let topicArn;
    let response;
    try {
        const secret = await getSecretValue('LambdaSNSTopicARN');
        topicArn = secret.TOPIC_ARN;
    } catch (err) {
        response = {
            statusCode: 500,
            body: JSON.stringify('An error occured, try again later.'),
        };
        console.error('Failed to load SNS Topic ARN from Secrets Manager', err);
        return response;        
    }

    // Publish to SNS topic
   try {
        const snsResponse = await snsClient.send(
        new PublishCommand({
            Message: name,
            TopicArn: topicArn,
        })
        );
        console.log("Message published successfully:", snsResponse.MessageId);
        response = {
            statusCode: 200,
            body: JSON.stringify(`Hello ${name}. Greetings from ExternalLambda1! Message forwarded to InternalLambda.`),
        };
        return response;
  } catch (err) {
        response = {
            statusCode: 500,
            body: JSON.stringify(`Sorry ${name}.An error occurred while processing your request.`),
        };
        console.error("Failed to publish message:", err);
        return response;
  }  
};

The code above leverages the AWS SDK to fetch the ARN of the SNS topic created earlier from Secrets Manager. It then publishes a message to the topic.

The SDK already comes installed in the Lambda function. Outside of Lambda, the SDK has to be explicitly installed. The function receives its event from the client via API Gateway, which we’ll configure later.

The SNS topic you created earlier will be the destination for this function. For Lambda to publish a topic to SNS, it needs the necessary permission attached to its IAM Role. AWS can automatically take care of that during your configuration, as shown below.

For the trigger, you’ll use another service known as API Gateway. More on that later.

ExternalLambda1 Add Destination

ExternalLambda1 Destination Permissions

Follow the same steps to provision another Lambda known as ExternalLambda2.

ExternalLambda2

The outcome of the External Lambda setup is as shown below:

ExternalLambda2 Overview

Paste the code below into ExternalLambda2. It performs the same function as ExternalLambda1, but their output differ. Each of the two Lambda functions will be receiving traffic to a specific user that’s authorized to access the function.

secure-lambda/ExternalLambda2/index.js

import {
  GetSecretValueCommand,
  SecretsManagerClient,
} from "@aws-sdk/client-secrets-manager";

import { SNSClient, 
    PublishCommand 
} from "@aws-sdk/client-sns";

const secretsManagerClient = new SecretsManagerClient();

const snsClient = new SNSClient({});

// Fetch topicArn from AWS Secrets Manager
async function getSecretValue(secretName) {
    try {
        const data = await secretsManagerClient.send(
                            new GetSecretValueCommand({
                            SecretId: secretName,
                            }),
                        );
        if (data.SecretString) {
            return JSON.parse(data.SecretString);
        }   else {
            let buff = Buffer.from(data.SecretBinary, 'base64');
            return JSON.parse(buff.toString("utf-8"));
        }
    } catch (err) {
        console.error('Error retrieving secret', err);  
        throw err;
    }
}                                        

export const handler = async (event) => {

    let name = event['name'];
    console.log(`Request successfully received from ${name}`);    

    // Retrieve SNS Topic ARN from Secrets Manager
    let topicArn;
    let response;
    try {
        const secret = await getSecretValue('LambdaSNSTopicARN');
        topicArn = secret.TOPIC_ARN;
    } catch (err) {
        response = {
            statusCode: 500,
            body: JSON.stringify('An error occured, try again later.'),
        };
        console.error('Failed to load SNS Topic ARN from Secrets Manager', err);
        return response;        
    }

    // Publish to SNS topic
   try {
        const snsResponse = await snsClient.send(
        new PublishCommand({
            Message: name,
            TopicArn: topicArn,
        })
        );
        console.log("Message published successfully:", snsResponse.MessageId);
        response = {
            statusCode: 200,
            body: JSON.stringify(`Hello ${name}. Greetings from ExternalLambda2! Message forwarded to InternalLambda.`),
        };
        return response;
  } catch (err) {
        response = {
            statusCode: 500,
            body: JSON.stringify(`Sorry ${name}.An error occurred while processing your request.`),
        };
        console.error("Failed to publish message:", err);
        return response;
  }              
};

Before moving on, you need to modify the External Lambda’s IAM Roles. Currently, IAM Roles only have permissions to write to CloudWatch and SNS (automatically added). External Lambda also needs permission to fetch the ARN of the SNS topic that was created earlier.

The point here is to show how to leverage a secrets manager, such as AWS Secrets Manager, to store sensitive information or data, and still access these securely. This approach is more secure than storing the ARN as an environment variable within Lambda.

Navigate to IAM, and click on Policies tab on the left. This brings you to a list of policies. Next, click on Create policy.

IAM Policies

Search for secrets manager in the Policy editor.

Policy Editor

Policy Editor2

Select the permissions Lambda needs to access Secrets Manager. In this case, that would be Read —> GetSecretValue.

Policy Editor - Specify Permissions

Select Specific for Resources, and click on Add ARNs. On the next tab, add the details of the Secrets Manager Secret created earlier.

Policy Editor - Select Access

The Secret’s ARN will be populated here.

Policy Editor - Add Secrets Manager ARN

Next, give the policy a name and create it.

Policy Editor - Create Policy

Newly Added Policy

Next, navigate to Roles, and search for the IAM Roles assigned to the External Lambda functions. These are named according to the Lambda.

IAM Roles

Lambda IAM Roles

Click Add permissions to add a new permission to the IAM Role selected.

ExternalLambda1 Role

ExternalLambda1 Role - Policy Added

ExternalLambda2 Role

ExternalLambda2 Role - Policy Added

Configure Web Application Firewall

A firewall is a system placed in front of an application, workload, APIs, and so on to inspect traffic, filter it, and either allow or block the traffic based on some preconfigured rules.

For this project, you’ll use the AWS Web Application Firewall (WAF) service to inspect user requests before routing the traffic to your APIs running in Lambda.

Head over to the AWS console and search for WAF.

AWS Web Application Firewall

Click on the IP sets tab on the left. This will enable you to create a list of IP addresses that you want to allow (as in this case) or deny.

IP Sets Page

IP Set Configuration

The IP addresses should include a CIDR block. For instance, if adding a single IP address, it should be X.X.X.X/32. The same applies to IP address ranges such as X.X.X.X/24.

IP Set Overview

Next, click on the Web ACLs tab, then Create web ACL.

Web ACL Page

Choose Regional resources as the Resource type, and enter your region. It’s best to keep all resources you’re creating in this project within the same region. Give your Web ACL a name, then click next.

Web ACL Description

Add rules to the Web ACL.

WAF Rule

Add Rule

Choose a rule type. In this case, you’ll use IP set, and give the rule a name. Choose the IP set created earlier.

Select Source IP address, and Count as the Action. For this project, you’ll focus on counting the requests sent to your APIs. But as shown in the image below, you can perform other actions, such as allow, block, and so on.

WAF Rule Configuration

Your final rule configuration will appear this way.

WAF Rule Overview

Scroll down, then click on Create web ACL.

Create Rule

Web ACL Dashboard

Configure Cognito User Pools

Amazon Cognito is an identity management service used for creating and managing users. You can leverage it to authenticate and authorize users to applications, APIs, or other workloads.

You’ll create User Pools within Cognito and add a user to each pool. You’ll configure how these users can be authenticated and authorized to access the External Lambda functions already created.

Search for Cognito on AWS.

Amazon Cognito

Click on Get started for free, then Create user pool.

Create User Pool

Select Single-page application (SPA), give the User pool the name MyUserPool1, and select Email as an option for sign-in. This means the main attribute users will provide at signup and sign-in will be their email address. Leave everything else as the default. We’ll keep things as simple as possible.

Use Pool Configuration

User Pool Configuration2

User Pool Configuration3

After creating the User pool, you’ll find the page shown below. You can view the login and signup page for the pool you’ve just created by clicking on the View login page button.

Cognito App Client Login URL

You can add App clients to your User Pool. By default, a client named MyUserPool1 will be added to the pool. Navigate to your User pool, and click on App clients to see details of this client. Note the Client ID. You’ll also make some edits to the App client by clicking on the Edit button.

User Pool App Client Overview

You’ll edit the Authentication flows field by ticking the Sign in with username and password… and Sign in with server-side administrative credentials… boxes. These changes will enable you to authenticate the user who will be added to this client programmatically, rather than through a UI. With this approach, we can fetch the token assigned to the user by Cognito and use this token to authorize access to Lambda.

Edit App Client

Now, add a user to this pool. The user needs a valid email address. You’ll need the login page URL to create the user.

Cognito Create New User

You need access to the email used to create the user. Fetch the code sent to the email address and submit to confirm the account.

Cognito Confirm Email

Cognito Successful Sign up

User Pool Users

Follow the same steps and create another User pool named MyUserPool2. Add a user with a different email to this pool.

Configure API Gateway

API Gateway is a service used to manage access and route traffic to API backend services such as APIs. It serves as a reverse proxy and provides an extra layer of security for backend services.

You’ll configure API Gateway to direct traffic to your Lambda functions.

Navigate to API Gateway and click on Create an API.

API Gateway

Select the REST API option —→ Build.

Select API Type

Select New API, provide a name, and choose Regional as the API endpoint type. IP address type can be IPv4 or Dualstack. We’ll select IPv4 here. Then create.

API Gateway Configuration

An important part of API Gateway configuration for this project is the Authorizer. API Gateway uses Authorizer to allow traffic from clients to backend services.

You’ll create two Authorizers. Each will be connected to one of the User pools you configured earlier. On the left-hand side of the API Gateway you configured, click on Authorizers —→ Create authorizer.

API Gateway Authorizer

Provide the name AGAuthorizer1, and select Cognito as the Authorizer type. Add the User pool for MyUserPool1 created earlier. For the Token source, use Authorization. When you send a request from your API client, a token will be added to the request header for authorization. The token’s key will be named Authorization, while the value will be the token itself.

Authorizer1 Configuration

Create another Authorization for MyUserPool2 the same way.

Authorizer2 Configuration

Both Authorizers will appear this way.

Authorizers Overview

Next, you’ll create resources and endpoints within the API Gateway that you’ve defined.

A resource in API Gateway is used to group certain endpoints within a specific path. You’ll define two resources within the API Gateway you’ve created. This will create two different paths, <BASE_URL>/<RESOURCE1> and <BASE_URL/RESOURCE2>.

On the API Gateway dashboard, navigate to your Gateway, click on Create resource, define your root path (‘/’ in your case), and provide the resource name (lambda1).

API Gateway Lambda1 Resource

Create another resource named lambda2.

API Gateway Resources Overview

Now, click on /lambda1, then Create method to define an endpoint within this resource. You’ll use the POST method to send requests to the backend service via this endpoint.

API Gateway Method Configuration

For the backend service or Integration type, select Lambda function, and provide the ARN of ExternalLambda1.

API Gateway Method Configuration2

For Authorization, select AWS IAM —→ Cognito user pool authorizers —→ AGAuthorizer1. Leave other configurations, then create the endpoint.

API Gateway Method Configuration3

Repeat the same step to create a POST method for /lambda2 resource. The method should be attached to ExternalLambda2, and AGAuthorizer2.

API Gateway Deployment

The API Gateway you’ve created needs to be deployed to become accessible. Deployment is usually done to a Stage.

Click on Deploy API, select New stage and name the stage development. Then, deploy.

API Gateway Stage

After deployment to a stage, an invoke URL will be provided. This will serve as the base URL for the endpoints you’ve defined.

API Gateway Stage Overview

The stage you’ve created needs some modifications for enhanced security. Firstly, you need to attach the WAF that you created earlier. Secondly, the default rate limit for the API deployed to this stage is 10000. Rate limit restricts excessive resource consumption and protects your API from abuse. For this project, you can reduce the limit to 50.

Edit API Gateway Stage

API Gateway Stage - Add Rate Limit and WAF

To test the API Gateway set up, click on the endpoint you want to test, then the Test button. This initial test doesn’t need any authorization, since the test is done directly within the Gateway.

API Gateway Endpoint Testing

Add JSON data as the Request body. The key will be name, and the value will be any string.

API Gateway Testing2

The response sent back from ExternalLambda1 shows a status code of 200, and a response body containing exactly the message expected from the Lambda function.

API Gateway Test Response

If you head over to CloudWatch Log groups, you should also find the Log groups that were automatically created for the Lambda functions. Click on the Log group for ExternalLambda1 and navigate to the latest Log stream. You should find the logs for the request you’ve just made from API Gateway.

CloudWatch Logs for Testing

CloudWatch Logs for Testing2

CloudWatch Logs - Output from InternalLambda

Test Setup End-to-End

To test our setup properly, and from the internet, send the same request from your API client with no additional information in the request header. This should return a 401 error – Unauthorized. This is expected.

Request without Token

API Gateway expects an authorization token from each request it receives before routing traffic to the appropriate backend service. It validates this token through Cognito.

You’ll mimic a user login for each user added to Coginito User pools to get a token for the user. This token will then be sent alongside any request. To achieve this, you’ll use the two Python scripts I’ve provided below:

secure-lambda/auth-scripts/user1.py

import boto3

client = boto3.client("cognito-idp")

response = client.initiate_auth(
    AuthFlow="USER_PASSWORD_AUTH",  # or ADMIN_USER_PASSWORD_AUTH if using admin creds
    AuthParameters={
        "USERNAME": "",             # user1 email
        "PASSWORD": ""              # user1 password
    },
    ClientId=""                     # Cognito App Client ID
)

id_token = response["AuthenticationResult"]["IdToken"]
access_token = response["AuthenticationResult"]["AccessToken"]
refresh_token = response["AuthenticationResult"]["RefreshToken"]

print("ID Token:", id_token)

Using the Python boto3 library, you’ll initiate an authentication request to Cognito. Provide the email address and password of the user in MyUserPool1. Also, add the Client ID of the App client.

To run the script, create an isolated environment using Pipenv, uv, or a similar library. Install the dependency used in the project as defined in the Pipfile, and run the script with the Pipenv shell.

pipenv install
pipenv shell
Python secure-lambda/auth-scripts/user1.py

The Python command will return with a token assigned to the user. Next, you use this token to authorize a user to access ExternalLambda1.

Add Token to Request Header

Ensure that the URL for the POST request is in the format: <BASE_URL/lambda1>. You should receive a response from API Gateway indicating success.

Now try accessing ExternalLambda2 using User1 token. You should get an Unauthorized message. Note that user1 will always receive an unauthorized message when it tries accessing ExternalLambda1 without an Authorization token in the header, a wrong token, or when it tries accessing ExternalLambda2, which it is not authorized to access.

User1 Access ExternalLambda2

Repeat the process with User2 using the token generated for the user in MyUserPool2. First, test access to ExternalLambda2 without a token in the request header.

User2 Request without Token

Then test access with the token.

User2 Request with Token

Next, try accessing ExternalLambda1 using User2.

User2 Access ExternalLambda1

You can also view the outcome of some of the requests made by your client on CloudWatch Logs.

CloudWatch Logs Output

CloudWatch Logs Output2

Also, since WAF has been configured previously to count requests (although, in a real scenario, you want to achieve much more with WAF, such as allow or block certain traffic), you can view activities captured by WAF by navigating to the service on AWS, then searching for the WAF you configured, and navigating to Traffic overview.

WAF - Traffic details

WAF - Traffic Details2

You can find other details, such as the client device types and where requests originated.

WAF - Traffic Details3

WAF - Traffic Details4

Clean Up

It’s important to clean up the resources created so far after the hands-on exercise. Due to the dependencies among the resources, trying to delete a resource that another resource depends on may lead to an error. So, you should delete them in this order:

  • Secrets Manager

  • Cognito – Users, App Client, then User Pool

  • API Gateway – Endpoints/ Methods, Resources, API, Stage

  • Web Application Firewall – IP Set, Web ACL

  • All Lambda Functions

  • Lambda IAM Roles and the policies attached to them

  • CloudWatch Log Group for all the Lambda functions

  • SNS Topic

Also, you can deactivate or delete the credentials created for your IAM Admin user if not in use.

Improvements

Consider the following areas to improve, apply best practices to, and enhance the security posture of your systems further.

  1. Use of API keys

  2. Third-party API consumption

  3. API inventory management/ documentation

  4. Resource provisioning using Infrastructure as Code

Conclusion

Security at every layer of an IT system is not negotiable. In this project, we’ve demonstrated how to leverage cloud-native solutions to secure APIs hosted in a serverless service, allowing only authorized users access to the APIs.

I’m Agnes Olorundare, and you can find out more about me on LinkedIn.