Calling an AWS Lambda function URL with IAM authentication in Python

Calling an AWS Lambda function URL with IAM authentication in Python

Using Mangum, you can host FastAPI and other ASGI apps on AWS Lambda. If you add a Lambda function URL, you don't even need to set up an API Gateway to expose the HTTP API to other services or the outside world. Even better, the function URL allows you to use AWS IAM to control who's allowed to access the API.

But it turns out that calling a function URL with IAM authentication enabled is not quite that simple. There are two things you have to get right: The configuration of the Lambda function and the HTTP request.

In principle, the AWS documentation contains all the required information and code examples. But it's spread across documentation for different services, lengthy, and full of jargon.

When I set up my first function URL, I couldn't find a complete, end-to-end example. This article fills this gap.

Prerequisites

We'll assume that you have

  • a Lambda function my-function-with-url that exposes an HTTP API and

  • a Lambda function my-calling-function that calls this API through an HTTP request.

(You can replace my-calling-function with code running on any other AWS service.)

Create a function URL configuration

First, we'll create a function URL with IAM authentication for my-function-with-url:

aws lambda create-function-url-config \
  --function-name my-function-with-url \
  --auth-type AWS_IAM

You'll find the URL in the response's FunctionUrl field. If you need it later, you can always retrieve it using the following CLI call:

aws lambda get-function-url-config \
            --function-name my-function-with-url \
            --query FunctionUrl --output text

Add a resource-based policy

By default, a Lambda function URL with IAM authentication cannot be called by anyone.

According to the documentation, as long as both Lambda functions are within the same AWS account, you can either add a resource-based policy to my-function-with-url or an identity-based policy to my-calling-function.

We'll go with the first option and add a resource-based policy to my-function-with-url that grants my-calling-function's role the lambda:InvokeFunctionUrl permission.

Using the AWS CLI, we fetch the ARN of our calling function's role and add a policy to my-function-with-url:

CALLING_FUNCTION_ROLE=$(aws lambda get-function \
                           --function-name my-calling-function \
                           --query Configuration.Role --output text)

 aws lambda add-permission \
   --function-name my-function-with-url \
   --action lambda:InvokeFunctionUrl \
   --principal $CALLING_FUNCTION_ROLE \
   --function-url-auth-type AWS_IAM \
   --statement-id allow-my-calling-function-to-invoke-url

Note that it seems to not be possible to simply choose "lambda.amazonaws.com" as the principal here. This should permit calls to the URL from all Lambda functions, but it doesn't work.

Also, keep in mind that cross-account calls to Lambda function URLs require a lambda:InvokeFunctionUrl permission on the calling side as well. For more information, see the AWS documentation.

Calling from another Lambda

With the function URL configured, it's time to call it.

There are at least two packages available on PyPI that help with creating properly signed and formatted requests: requests-aws4auth and requests-auth-aws-sigv4. (It's also possible to assemble the headers and payload yourself.)

However, if you just want to make requests from another service on AWS (like our my-calling-function), the easiest way is to use botocore. This library powers both the AWS CLI and boto3 behind the scenes.

The following examples use the requests library, but you can also use httpx or any other HTTP client.

GET requests

Here's a minimal example of an HTTP GET request to a Lambda function URL:

import requests

import botocore.session
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest

session = botocore.session.Session()
sigv4 = SigV4Auth(session.get_credentials(),
                  service_name="lambda",
                  region_name="eu-central-1")

url = "https://XYZ.lambda-url.eu-central-1.on.aws/book?page=5"

request = AWSRequest(method="GET", url=url)
sigv4.add_auth(request)
signed = request.prepare()

response = requests.get(signed.url, headers=signed.headers)

To adapt this example, you need to replace "eu-central-1" with your function's AWS region and replace url with your function's URL.

Both service_name and region_name refer to the receiver of the request, not its sender. So even if the code making the request runs in a different AWS service or even outside of AWS, service_name always has to be set to "lambda".

Note that signed.url and url do not necessarily differ. But it's a good practice to always use signed.url in the request. When calling request.prepare(), the URL might get reformatted, the query parameters get sorted, and new query parameters will be added if you provide them when instantiating AWSRequest. The URL, including the query string, is part of the signature. Thus, using the original url will result in authentication failures if any modifications have been made internally.

(Have a look at the source code of SigV4Auth for all the details.)

POST requests

Here's a minimal example of an HTTP POST request to a Lambda function URL:

import json
import requests

import botocore.session
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest

session = botocore.session.Session()
sigv4 = SigV4Auth(session.get_credentials(),
                  service_name="lambda",
                  region_name="eu-central-1")

url = "https://XYZ.lambda-url.eu-central-1.on.aws/login"
data = {"user": "my-user", "token": "ABC123"}

request = AWSRequest(method="POST", url=url, data=json.dumps(data))
sigv4.add_auth(request)
signed = request.prepare()

response = requests.post(signed.url,
                         headers=signed.headers,
                         data=signed.body)

To adapt this example, you again need to replace "eu-central-1" with your function's AWS region, replace url with your function URL, and data with your payload.

As with the GET request, you should always use signed.url instead of url to avoid authentication failures due to modifications to the URL while calling request.prepare().

It's crucial to pass the payload to AWSRequest with json.dumps(data). Otherwise, request.prepare() will convert your payload into a query-string-like format.

(Again, I recommend to have a look at the source code of SigV4Auth for all the details.)