Solving the Puzzle: Lambda Function URLs with IAM Authorization and CloudFront Custom Domains
Tags: aws, webdev, tutorial, programming
Published: 2024-01-29 09:13:16

In this short article, I'd like to share some insights I gained from a recent issue involving the combination of Lambda Function URLs, IAM authorization, and custom CloudFront domains. I hope it will help someone out there who may be struggling with this exact problem right now. So, without further ado, let's get started.

---

[Lambda Function URLs](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) are a powerful feature. They notably extend the HTTP timeout to up to 15 minutes, a significant increase from the 30 seconds provided by API Gateway, and also support response streaming. Lambda Function URLs typically follow this format:

```
https://<url-id>.lambda-url.<region>.on.aws
```

To secure your Lambda function against unauthorized access, you can attach an [IAM authorizer](https://docs.aws.amazon.com/lambda/latest/dg/urls-auth.html) to its URL. This process involves signing your HTTP request with IAM credentials according to the [AWS Signature V4](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-signing.html) specification. A convenient way to handle this is by using libraries like [aws-sigv4-fetch](https://www.npmjs.com/package/aws-sigv4-fetch) or similar tools, which automate the signature calculation and add the `Authorization` header to your HTTP request.

Attempting to invoke your Lambda Function URL without a valid `Authorization` header will result in a _403 Forbidden_ response.

![Invoke Lambda Function URL with unsigned request](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9hnwqlijfg5fk2kdpo6v.png)
<figcaption>Invoke Lambda Function URL without valid credentials</figcaption>


However, signing the request with appropriate credentials, as shown here using Thunder Client, should yield a successful response.

![Invoke Lambda Function URL with signed request](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/agza97dudehwtbzfpv1f.png)
<figcaption>Invoke Lambda Function URL with valid credentials</figcaption>

Now, you might be tempted to assign a custom domain to your Lambda function because you want to call the function from a different service and not have to worry about cross-stack imports. For example, I like to add the deployment phase (development, staging, production) as a subdomain to my custom domains:

```
https://<service>-<stage>.example.com
```

Unlike API Gateway, Lambda Functions do not natively support custom domains. However, they can be configured manually using CloudFront and Route53, which both services use under the hood. Setting up a CloudFront distribution to use Lambda Function URLs as a [custom origin](https://aws.amazon.com/blogs/networking-and-content-delivery/using-amazon-cloudfront-with-aws-lambda-as-origin-to-accelerate-your-web-applications/) forwards all requests to your Lambda Function.

![CloudFront distribution with Lambda Function URL as origin](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gevflcy058q1ghzh2sfo.png)
<figcaption>CloudFront distribution with Lambda Function URL as origin</figcaption>


So far, so good. Now let's repeat the previous HTTP request, but with the custom domain. By the way, I have not assigned an alternative domain name and simply used the CloudFront domain here, but the result will be the same: 

![Invoke CloudFront distribution URL with valid credentials](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wakhrm32on2ldng8745v.png)
<figcaption>Invoke CloudFront distribution URL with valid credentials</figcaption>

Status _403 Forbidden_...Okay, what happened here? 
I assume that everyone who has worked with Signature V4 has come across this error message at some point:

> The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details.

To understand this message, let's start with a quick summary of what Signature V4 does. If you want to send an HTTP request to an AWS service, you need to send your identity and proof of authenticity with every HTTP request. According to the [Signature V4 spec](https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html), you must write down each HTTP request in a specific way. This is your [canonical request](https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html#create-canonical-request) and it is very important that two identical HTTP requests result in the same canonical request. The canonical request is concatenated with other metadata to form a signable string, which is then hashed (SHA256) using your AWS credentials. This hash is added to your HTTP request as an `Authorization` header.

![AWS Signature V4 signing process](https://docs.aws.amazon.com/images/AmazonS3/latest/API/images/sigV4-using-auth-header.png)
<figcaption>AWS Signature V4 signing process</figcaption>

AWS replicates this process upon receiving your request and calculates the signature for it. Since AWS has access to the access key and the secret key that you used for the calculation, AWS should be able to create the same signature. If this is not the case, access will be denied with the error message mentioned above.

So, what went wrong along the way? Let's take a look at the behavior of CloudFront, which forwards the request to the Lambda function. The [origin request policy](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/controlling-origin-requests.html) controls how CloudFront forwards the request to Lambda:

![CloudFront origin policies](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/andrqvx0ceg1e8d05bvo.png)
<figcaption>CloudFront origin policies</figcaption>

The `AllViewerExceptHostHeader` origin request policy is recommended for Lambda Functions URLs. It is a managed policy, which means we do not have to create it ourselves. This is how it is defined:

![CloudFront managed origin request policy](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ibsoo00eehkvtbzehe85.png)
<figcaption>CloudFront managed origin request policy</figcaption>


As the name implies, this policy forwards all HTTP headers of the client request, _except_ the `Host` header. But why is this important? When you call your Lambda function via its function URL, you are actually calling the Lambda service in a specific region. This service receives your request and identifies the Lambda function to be called using the unique ID from the Lambda function URL. It then calls your Lambda function with the HTTP request as the payload. For this to work, CloudFront must change the `Host` header from the CloudFront domain `<distribution-id>.cloudfront.net` of the client request to the domain of your Lambda Function URL `<url-id>.lambda-url.<region>.on.aws`.

Next comes the IAM authorizer, which is assigned to your Lambda function to protect it from unknown invocations. The IAM authorizer receives the HTTP request from CloudFront and checks its validity. It repeats the process to calculate the signature for this HTTP request according to the Signature V4 specification. However, the request from CloudFront has been slightly modified by the `Host` header. Yet this small change results in a completely different signature. The signature from the client does not match the calculated signature and therefore the request is denied with status _403 Forbidden_ and the error message shown above.

---

So what is next? There is a possible workaround for this issue by using the Lambda Function URL as the `Host` to calculate the signature, but then sending the request to CloudFront. This means that you calculate the signature that the IAM authorizer will calculate after CloudFront has changed the host to the Lambda Function URL. I don't really recommend this approach, but I wanted to mention it anyway. Personally, I would like to see a change in the combination of CloudFront and Lambda Function URLs. For example, CloudFront could leave the `Host` header unchanged and instead add the Lambda Function URL to a custom header like [`X-Forwared-Host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host). These headers are not included in the Signature V4 singing process and therefore do not change the calculated signature. Lambda could still check the `Host` header, but would fall back to the `X-Forwared-Host` header as an alternative.