This post is a follow-up tutorial for Shane Nolan’s amazing post on AWS’ HTTPS function URLs. This time, I’ve decided to tighten the belt around the HTTPS endpoint and only allow authenticated calls.

According to AWS, the best way to achieve this is through IAM roles.

While trying to come up with a concise cookbook for this scenario, the investigation took me down a deep rabbit hole. Unfortunately, there was no clear guidance available on the matter.

Even ChatGPT seemed to be of very little help.

I hope that by reading this post, you’ll be able to grasp the concepts involved in creating this cookbook.

So, let’s begin our journey, shall we?

Prerequisites

  • An AWS account.
  • AWS credentials configured, so you can deploy the resources described in the main.tf file.
  • Python 3 installed on your device.
  • Hashicorp’s Terraform installed.
  • Having read Shane’s article and implemented it.
  • Basic familiriaty with the concepts of AWS users and IAM.

Note that I’ve simplified the deployment of the function. My sample lambda_handler does not require any dependencies.So, I just zipped the file containing it (i.e. lambda_functino.py) into the package.zip archive.

The source code for the tutorial is available here.

Inspiration and sources

Besides Shane’s article, I used Omkar Bide’s spectacular artilce about what the AWS IAM roles are and what they consist of. I definitely recommend you read this article after you read my post.

The Elephant in the Room - Simplified

Before we proceed with the tutorial, we need to address the elephant in the room, which is IAM roles.

Let’s summarize this concept based on Omkar Bide’s post on the matter:

  • Prior to IAM roles (which are a special type of identity), we had the following types of identities: AWS users (which can be human or programmatic) and Federated Identities (identities originating from outside of AWS, such as Google and Microsoft accounts, etc.).
  • IAM roles are a type of identity, but they cannot exist on their own. They need to be assumed by other types of identities, such as the ones mentioned above and different AWS services, like Lambdas, EC2 instances, etc.
  • When an IAM role is assumed through AWS STS (Security Token Service), the AWS STS provides a set of temporary credentials (valid for up to 12 hours) to access the required resources.
  • An IAM role consists of two key features: 1) Who can assume it - known as the trust policy. In Terraform, it’s referred to as assume_role_policy. 2) What it can do - known as the IAM policies.

According to Omkar, one of the advantages of AWS IAM roles is their infrequent changes (similar to access policies) compared to actual users/applications (e.g., employees leaving the organization and new ones joining).

In contrast to permanent traditional secret keys, IAM roles don’t have any secrets associated to them. Once a role is assumed, the credentials are temporary and last for up to 12 hours.

This practice enhances security, as leaked temporary credentials would be useless once they expire, so no resources would be impacted.

Altough the credentials that are granted by the AWS STS are temporary, we should keep a close eye on the credentials of the AWS IAM user, as it still assumes an IAM role with its secret key and secret key id.

If we go back to Shane’s tutorial, we can see that he actually sets up an IAM role for the Lambda (as it’s required by Terraform).

In the original file, the trust policy allows only the Lambdas in your account to assume the IAM role.

To keep the post simple, Shane does not specify any permissions for the Lambda he’d created. Indeed, no specific permissions are need to respond to HTTP calls.

However, in our case we’d like to limit access to the HTTP endpoint. So, the upcoming section will expand on the concepts demonstrated in the “Creating an HTTPS Lambda Endpoint without API Gateway” tutorial.

The Ingredients

Below are the ingredients for the cookbook. Here’s what we need to make the HTTPS endpoint accessible only via AWS IAM role interim credentials.

Alter the function’s URL access

First, let’s modify access to the URL in the following way:

resource "aws_lambda_function_url" "lambda_tutorial_url" {
  function_name      = aws_lambda_function.lambda_tutorial.arn
  authorization_type = "AWS_IAM"
}

Note that the only change made here is to the authorization_type property. From now on, only authorized access to the URL is allowed.

IAM user

I’d like to create a special AWS IAM user who will access the AWS services.

Its credentials will be visible to the user who operates the resources deployment. Since we will create a special IAM role later, there would be no need to share the user’s credentials.

Not sharing the credentials reduces the risk of potential credential leakage.

Here’s how we create a new user in Terraform:

resource "aws_iam_user" "lambda_invoker" {
  name = "lambda_invoker"
}

The user’s credentials

As mentioned earlier, to assume the role, the user would still need a set of credentials.

To generate the secret, use these directives in Terraform:

resource "aws_iam_access_key" "lambda_invoker_key" {
  user    = aws_iam_user.lambda_invoker.name
}

Note that the user’s credentials are available as clear text in the terraform.tfstate file generated after the AWS resources described in the main.tf file were deployed.

There are other ways to protect the Terraform State file, but that’s beyond the scope of this tutorial.

Personally, I excluded this file from the repository altogether.

IAM role

Now we’re moving on to the holy grail of the tutorial - the IAM role itself.

This is how you declare it in Terraform:

resource "aws_iam_role" "function_invoker" {
  name = "function_invoker"
  assume_role_policy = jsonencode(
    {
        Version = "2012-10-17"
        Statement = [{
            Sid      = ""
            Effect   = "Allow"
            Action   = "sts:AssumeRole"
            Principal = {
              AWS = aws_iam_user.lambda_invoker.arn
            }
        }]
    })
}

As described above, the assume_role_policy (trust policy) allows only a specific user to assume it. You can see this annotation under the Principal property. In this particular case it’s of type AWS and refers to the newly created user’s ARN.

As we discussed earlier, converse entities can assume an IAM role, such as AWS users, services, Federated Identieis and roles (yes, you’ve read it right - a role can assume a role).

The entities can also originate from different AWS accounts.

For additional reference, you can refer this documentation

The next part of the IAM role would be what it allows to do for the ones who assume it.

AWS IAM policy

The policy describes a set of permissions for an identity.

This is how we declare it in Terraform:

resource "aws_iam_policy" "invoke_lambda_permissions" {
  name        = "InvokeSingleLambdaPolicy"
  description = "Custom policy for invoking Lambda functions and function URLs"

  policy = jsonencode({
    Version   = "2012-10-17"
    Statement = [
      {
        Sid       = "AllowInvokeFunction"
        Effect    = "Allow"
        Action    = [
          "lambda:InvokeFunction",
          "lambda:InvokeAsync",
        ]
        Resource  = aws_lambda_function.lambda_tutorial.arn
      },
      {
        Sid       = "AllowInvokeFunctionURL"
        Effect    = "Allow"
        Action    = [
          "lambda:InvokeFunctionURL",
        ]
        Resource  = aws_lambda_function.lambda_tutorial.arn
      }
    ]
  })
}

As you can observe, there are two Statements attached to the policy. The policy described above allows only to invoke the Lambda we’d created earlier or refer its URL.

Namely, we devised here a custom policy. As opposed to AWS predefined policies, this policy restricts access to only the function that we created.

We won’t discuss AWS’ predefined IAM policies for the sake of brevity. A detailed reference is available here.

These are called AWS Managed Policies.

AWS IAM role <-> policy attacment

As we discussed earlier, the IAM role-based access consists of two key parts, i.e. the trust policy and iam policies. Namely, who can assume the role and what they can do with it.

Now it’s time to connect these two pieces together. In Terraform we do it the following way:

resource "aws_iam_role_policy_attachment" "lambda_invoker_role_policy_attachment" {
  role       = aws_iam_role.function_invoker.name
  policy_arn = aws_iam_policy.invoke_lambda_permissions.arn
}

If you’d like to use an AWS managed policy, you can skip the declaration of the custom policy above and use the AWS manage policy’s ARN instead. For example: arn:aws:iam::aws:policy/AWSLambda_FullAccess.

The policy in the example allows access to several resources, including modifying the IAM roles themselves.

Before opting for AWS managed policies, carefully review their permissions.

The outputs

To call a function, you’d need the following paramaters. For the sake of convenience, I grouped them into the following outputs:

output "function_invoker_user_access_key_id" {
  description = "Function invoker access key id"
  value       = aws_iam_access_key.lambda_invoker_key.id
}

output "function_invoker_user_secret" {
  description = "Function invoker access key"
  value       = aws_iam_access_key.lambda_invoker_key.secret
  sensitive   = true
}

output "function_invoker_role_arn" {
  description = "Function invoker ARN"
  value       = aws_iam_role.function_invoker.arn
}

output "function_url" {
  description = "Function URL."
  value       = aws_lambda_function_url.lambda_tutorial_url.function_url
}

Terraform won’t print the secret in the command line, but it would still be available in the Terraform State file under the outputs section.

Taking the Function for a Ride

To invoke the function with AWS credentials, you’d need to implement the following flow:

  1. Get the needed paramaters from the terraform.tfstate file, including the user’s credentials, the newly created role’s ARN and the function URL.
  2. Assume the role with the newly created user’s credentials via AWS STS. Additional documentation on how to authenticate agains AWS STS is available here
  3. Call the function with the temporary credentials.

I automated the flow in the demonstrate_secure_lambda_invocation.py script.

To call the function, I used the highly popular requests library along with the requests-auth-aws-sigv4 package, which provides the signed credentials for the function.

To run the whole tutorial suite, run the below commands (on macOS & Linux):

git clone https://github.com/nickminaev/lambda_with_aws_iam_access.git
cd lambda_with_aws_iam_access
terraform init
pip3 install -r requirements.txt
./deploy.sh
python3 demonstrate_secure_lambda_invocation.py

The wrap-up

To sum up our quest, we implemented secure access to the function’s HTTPS endpoint.

To do so, we needed to assemble a few pieces of the puzzle, which are:

  • AWS user (whose credentials don’t need to be shared anymore)
  • IAM role (in whose trust policy only the user above could assume it)
  • IAM policies (according to which only the specified function could be invoked or its URL could be accessed)
  • Eventually, we connected these together by aws_iam_role_policy_attachment

I hope this article was helpful and concise, so you’d know how to easily secure an function endpoint.

We could have opted for a custom authentication mechanism, but reinventing the wheel might sometimes prove to be risky. Instead, we used an existing mechanism provided by AWS.

Happy coding!