Deploy Hono Lambdalith APIs with Lambda Function URLs and CloudFront OAC
1. Introduction
Over the past few years, single-purpose functions have been the recommended approach. Putting an entire web framework like Express into a Lambda function was deemed too heavy and slow. Usually, you’d take advantage of API Gateway’s routing capabilities to route requests to the correct Lambda function. While there’s nothing wrong with this approach, there are alternatives.
Lambdaliths, where you have monolithic API functions, have grown in popularity lately. As I wrote in my previous post, Lambdaliths have become my preferred approach when starting new APIs. To read more about why you should consider using Lambdaliths, Rehan van der Merwe wrote a great post on the topic.
When AWS released Lambda Function URLs, they opened up an even more straightforward way to deploy a Lambdalith. You could now lower costs by refraining from using API Gateway altogether. However, some features of API Gateway, such as Web Application Firewall (WAF) for REST APIs, were missing. You could put a CloudFront distribution before the Lambda Function URL and attach a WAF. However, like with HTTP API Gateways, you couldn’t easily restrict access to only allow traffic coming from CloudFront.
But now, AWS has released Origin Access Control (OAC) for Lambda Function URLs. This feature allows you to restrict access to a Lambda Function URL, ensuring that all traffic goes through a CloudFront distribution. In this post, you’ll learn how to build a Lambdalith API with Hono and deploy it with Lambda Function URLs and CloudFront OAC.
POST/PUT requests
When OAC was released, there was a lot of fuss about OAC not supporting POST/PUT requests without signed payloads. Some recent findings suggest an approach to work around this limitation.
You’ll also see how to add bearer authentication using the Authorization
header and how to work around the fact that CloudFront overwrites it when using OAC.
We will also compare some costs and see how you can save up to 66.7% by deploying Lambdaliths with Function URLs and CloudFront instead of REST API Gateways.
2. Let’s build
You’ll build a simple Lambalith API with TypeScript and deploy it using AWS SAM. You can use sam init
to bootstrap a new SAM project.
2.1. Creating the Lambdalith
Let’s start with creating a simple Lambalith API. In this example, you will use Hono, but you can use any other web framework with similar results. Make sure you install hono
with your package manager of choice. Create a new file src/index.ts
with the following contents:
This creates an API with a single endpoint, /hello
, which returns a JSON object with the message "Hello, World!"
.
Add the function to your SAM template file:
After deploying with sam deploy
, you can access the API using the URL in the ApiURL
output. You can test the /hello
endpoint with httpie (or any other HTTP client):
It’s all good so far. With a few lines of code, you have deployed an Hono API on AWS Lambda. However, the URL to access the API could be more user-friendly. Unfortunately, you can’t associate a custom domain names with a Lambda Function URL. To do so, you must front the API with a CloudFront distribution.
2.2. Adding a CloudFront distribution
In your SAM template, add a CloudFront distribution resource:
This creates a CloudFront distribution with the Lambda Function URL as its origin. The distribution uses the recommended settings for caching and origin request policies.
After deploying, you should now be able to access the API through the CloudFront distribution URL:
2.3. Restricting direct access to the API
Right now, your API is accessible through both the Lambda Function URL and the CloudFront distribution URL. You often require all production traffic to pass through a Web Application Firewall attached to the CloudFront distribution. The Lambda Function should thus only accept traffic coming from CloudFront. You can do this with the new Origin Access Control feature.
To enable OAC, you have to:
- Set the
FunctionUrlConfig.AuthType
property of the Lambda Function toAWS_IAM
. - Give the CloudFront distribution access to invoke the Lambda Function URL.
- Add an
AWS::CloudFront::OriginAccessControl
resource. - Update the origin to use the OAC.
In your template, add the following:
Deploy the API and try calling the API through CloudFront and the function URL. CloudFront should let you through. But, when accessing the Lambda Function directly, you should get a 403 Forbidden
response:
2.4 What about POST/PUT requests?
When OAC was released, I was thrilled to have a cheaper way to deploy Lambdaliths. That was, until I read the docs and found the following text:
If you use PUT or POST methods with your Lambda function URL, your user must provide a signed payload to CloudFront. Lambda doesn’t support unsigned payloads.
It sounds like you have to sign the payload using IAM credentials, and handing out those to every client sounds like a disaster. Fun fact: I discovered this after this post was almost complete. Those two sentences in the docs invalidated the entire post, and I put it on hold.
Fast forward a few weeks, and I stumbled upon this tweet that suggested a workaround using the x-amz-content-sha256
header. Let’s try it out.
First, add a new POST endpoint to your API:
Deploy your API and try sending a POST request to the /post
endpoint:
You should get a 403 Forbidden
response with a message stating that the signature is invalid. The error message sounds like a SIGv4 header is missing. However, according to the tweet mentioned earlier, simply calculating the SHA256 hash of the payload and sending it in the x-amz-content-sha256
header should work.
Let’s try it out:
While not ideal, the workaround works. Expecting clients to calculate the SHA256 hash of the payload is not user-friendly. However, it’s a small price that could be worth paying for overall lower operating costs. If you provide an SDK to your clients, you could abstract away the hash calculation. Calculating it is as simple as:
Sadly, CloudFront functions do not have access to the request body. If they did, you could calculate the hash at the edge and add the header before forwarding the request to the Lambda function, which would save the clients from having to send the header themselves.
Lambda@Edge does have access to the request body. However, it comes with a much higher price tag than CloudFront functions. There is also a limit to how large bodies that Lambda@Edge can handle. CloudFront truncates the body at different sizes, depending on whether it is a viewer or an origin request function.
2.5. Adding bearer authentication
Right now, your API has one public /hello
endpoint. Let’s add a new endpoint, /secret
, that requires a secret token in the Authorization
header. Let’s add a middleware and the /secret
endpoint to the Hono API:
You should now be able to access the /secret
endpoint by providing an Authorization
header with the value super-secret
:
This doesn’t look right. The middleware should pick up the Authorization
header and let you through. Let’s see what’s going on.
2.6. Where is the Authorization header?
It seems that our API does not receive the Authorization
header. To verify this, update the /hello
endpoint to return all incoming headers in the response:
Deploy and send a request to the /hello
endpoint and check the response. Note the absence of the Authorization
header.
What is going on here? The issue comes from the CloudFront OAC configuration. In the SAM template, we have set the SigningBehavior
property to always
to make CloudFront sign all origin requests. CloudFront overwrites the Authorization
header if present while doing so. You could set the property to no-override
to avoid overwriting the header. However, clients must then sign all requests using IAM credentials to bypass the AWS_IAM
authentication. Handing out such credentials to clients is not a good idea.
Another solution would be to use a non-standard header such as X-Authorization
instead of Authorization
. This might be sufficient for some use cases, but in others it would not. Perhaps you are migrating an existing API that uses the Authorization
header, or API standards in your organization mandate the use of the Authorization
header.
Let’s see how we can solve this issue with another CloudFront feature.
2.7. Rewrite the Authorization header with CloudFront Functions
You can use CloudFront Functions to manipulate requests and responses at the edge. In this case, you can intercept the request and modify the headers before it reaches the Lambda function. If the client sends an Authorization
header, we will copy it to a new header named X-Authorization
. This way, the original Authorization
header will be accessible in the Hono middleware as X-Authorization
.
Add the CloudFront Function to the SAM template and associate it with the distribution:
Now, clients can use the standard Authorization
header, and your Lambdalith will receive the value in the X-Authorization
header. Update the middleware to use the new header:
Deploy the application and test the /secret
endpoint again:
That’s it! You have successfully deployed a Lambdalith API using Hono with only a Lambda Function URL and CloudFront. You have also ensured that all traffic must go through CloudFront and added support for both public and private endpoints using standard token authentication.
One significant benefit of this solution is that you can easily add a WAF to the CloudFront distribution to protect your API from malicious traffic. By requiring that all traffic goes through CloudFront, you can sleep better at night knowing that all traffic will pass through the WAF. If only it were this easy to force traffic through a WAF for all APIs (I’m looking at you, HTTP API Gateway…)!
3. What about costs?
My immediate thought when AWS announced the OAC release was all the money I could save by not having to use REST API Gateways. Let’s compare some numbers in the eu-west-1
region, excluding free tiers:
CloudFront | |
---|---|
Per million requests | $0.90 |
Per million invocations (CloudFront Functions) | $0.10 |
Data transfer out | $0.085/GB |
REST API Gateway | |
Per million requests | $3.50 |
Data transfer out | $0.090/GB |
HTTP API Gateway | |
Per million requests | $1.00 |
Data transfer out | $0.090/GB |
Let’s assume you have an API that receives 100 million requests per month, with each API call returning responses of 3 KB in size.
- CloudFront: $90 (requests) + $10 (invocations) + $25.50 (data transfer) = $125.50
- REST API Gateway: $350 (requests) + $27 (data transfer) = $377
- HTTP API Gateway: $100 (requests) + $27 (data transfer) = $127
Using CloudFront + Lambda Function URLs instead of REST API Gateway results in a 66.7% cost reduction. For high-traffic APIs, API Gateway can quickly become the most expensive service in your stack. Switching to Function URLs where applicable can save you a large chunk of that cost.
Why not stick to HTTP APIs?
While HTTP APIs are at a similar price point to CloudFront + Lambda Function URLs, you lose the ability to attach a WAF natively. If your use case requires a WAF, you need a CloudFront distribution before the HTTP API, thus increasing the cost.
4. Conclusion
Lambdaliths are here to stay. They provide developers with a simple and familiar way to build APIs. Deploying Lambdaliths on Lambda Function URLs and using CloudFront OAC to control access gives you the best of both worlds. You get the simplicity and cost-effectiveness of Lambda Function URLs while getting CloudFront’s security and performance benefits.
You also get rid of the 29-second request limit that REST APIs have. I don’t usually see synchronous APIs that take that long to respond, but it’s nice to know that you don’t have to worry about it.
For high-traffic APIs, you can save up to 66.7% of the cost of deploying the same Lambdalith behind a REST API. HTTP APIs are a cheaper alternative to REST APIs, but they do not support WAF.
Like everything in software, this solution comes with trade-offs. For example, if you are a fan of Lambda-less architectures using direct integrations, you might want to stick to REST APIs.
The biggest downside to this approach is the lack of support for POST/PUT requests without signed payloads. You must do extra work on the client side to calculate and include the SHA256 hash. While not ideal, the overall cost savings could be worth it.