eliasbrange.dev
Deploy Hono Lambdalith APIs with Lambda Function URLs and CloudFront OAC

Deploy Hono Lambdalith APIs with Lambda Function URLs and CloudFront OAC

2024-05-23
| #AWS #Serverless

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:

src/index.ts
1
import { Hono } from "hono";
2
import { handle } from "hono/aws-lambda";
3
4
const app = new Hono();
5
6
app.get("/hello", async (c) => {
7
return c.json({
8
message: "Hello, World!",
9
});
10
});
11
12
export const handler = handle(app);

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:

template.yaml
1
AWSTemplateFormatVersion: '2010-09-09'
2
Transform: AWS::Serverless-2016-10-31
3
4
Resources:
5
ApiFunction:
6
Type: AWS::Serverless::Function
7
Properties:
8
CodeUri: src
9
Handler: index.handler
10
Runtime: nodejs20.x
11
MemorySize: 1769
12
Architectures:
13
- arm64
14
Timeout: 300
15
FunctionUrlConfig:
16
AuthType: NONE
17
Metadata:
18
BuildMethod: esbuild
19
BuildProperties:
20
Minify: true
21
Target: 'es2020'
22
Sourcemap: true
23
EntryPoints:
24
- index.ts
25
26
Outputs:
27
ApiURL:
28
Description: 'API Gateway endpoint URL'
29
Value: !GetAtt ApiFunctionUrl.FunctionUrl

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):

Terminal window
1
$ http https://<your-function-url-id>.lambda-url.eu-west-1.on.aws/hello
2
HTTP/1.1 200 OK
3
#...
4
{
5
"message": "Hello, World!"
6
}

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:

template.yaml
1
Resources:
2
ApiFunction:
3
Type: AWS::Serverless::Function
4
# ...truncated...
5
6
Distribution:
7
Type: AWS::CloudFront::Distribution
8
Properties:
9
DistributionConfig:
10
DefaultCacheBehavior:
11
TargetOriginId: 'ApiFunctionOrigin'
12
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled
13
OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac # AllViewerExceptHostHeader
14
ViewerProtocolPolicy: redirect-to-https
15
AllowedMethods:
16
- 'GET'
17
- 'HEAD'
18
- 'OPTIONS'
19
- 'PUT'
20
- 'PATCH'
21
- 'POST'
22
- 'DELETE'
23
Enabled: true
24
Origins:
25
- Id: 'ApiFunctionOrigin'
26
DomainName:
27
!Select [2, !Split ['/', !GetAtt ApiFunctionUrl.FunctionUrl]]
28
CustomOriginConfig:
29
OriginProtocolPolicy: https-only
30
31
Outputs:
32
ApiURL:
33
Description: 'API Gateway endpoint URL'
34
Value: !GetAtt ApiFunctionUrl.FunctionUrl
35
DistributionUrl:
36
Description: 'CloudFront distribution URL'
37
Value: !Sub 'https://${Distribution.DomainName}'

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:

Terminal window
1
$ http https://<distribution-id>.cloudfront.net/hello
2
HTTP/1.1 200 OK
3
#...
4
{
5
"message": "Hello, World!"
6
}

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:

In your template, add the following:

template.yaml
1
Resources:
2
ApiFunction:
3
Type: AWS::Serverless::Function
4
Properties:
5
# ...
6
FunctionUrlConfig:
7
AuthType: AWS_IAM
8
Metadata: # ...
9
10
ApiFunctionPermission:
11
Type: AWS::Lambda::Permission
12
Properties:
13
Action: lambda:InvokeFunctionUrl
14
FunctionName: !Ref ApiFunction
15
Principal: cloudfront.amazonaws.com
16
SourceArn: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${Distribution}'
17
18
Distribution:
19
Type: AWS::CloudFront::Distribution
20
Properties:
21
DistributionConfig:
22
DefaultCacheBehavior: # ...
23
Enabled: true
24
Origins:
25
- Id: 'ApiFunctionOrigin'
26
DomainName:
27
!Select [2, !Split ['/', !GetAtt ApiFunctionUrl.FunctionUrl]]
28
CustomOriginConfig:
29
OriginProtocolPolicy: https-only
30
OriginAccessControlId: !Ref OAC
31
32
OAC:
33
Type: AWS::CloudFront::OriginAccessControl
34
Properties:
35
OriginAccessControlConfig:
36
Name: ApiFunctionOAC
37
OriginAccessControlOriginType: lambda
38
SigningBehavior: always
39
SigningProtocol: sigv4

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:

Terminal window
1
$ http https://<your-function-url-id>.lambda-url.eu-west-1.on.aws/hello
2
HTTP/1.1 403 Forbidden
3
#...
4
{
5
"Message": "Forbidden"
6
}

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:

1
app.post("/post", async (c) => {
2
return c.json({
3
message: "Hello, Post!",
4
});
5
});

Deploy your API and try sending a POST request to the /post endpoint:

Terminal window
1
$ http post https://<distribution-id>.cloudfront.net/post name="John Doe" age:=32
2
HTTP/1.1 403 Forbidden
3
#...
4
{
5
"message": "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."
6
}

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:

Terminal window
1
$ http post https://<distribution-id>.cloudfront.net/post name="John Doe" age:=32 x-amz-content-sha256:$(echo -n '{"name": "John Doe", "age": 32}' | openssl dgst -sha256 -hex | cut -d' ' -f2)
2
3
HTTP/1.1 200 OK
4
#...
5
{
6
"message": "Hello, Post!"
7
}

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:

1
import { createHash } from 'crypto';
2
3
const payload = JSON.stringify({
4
name: 'John Doe',
5
age: 32,
6
});
7
8
const response = await fetch(URL + '/post', {
9
method: 'POST',
10
body: payload,
11
headers: {
12
'Content-Type': 'application/json',
13
'x-amz-content-sha256': createHash('sha256').update(payload).digest('hex'),
14
},
15
});

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:

src/index.ts
1
import { Hono } from "hono";
2
import { handle } from "hono/aws-lambda";
3
import { HTTPException } from "hono/http-exception";
4
5
const app = new Hono();
6
7
app.use("/secret", async (c, next) => {
8
const token = c.req.header("Authorization");
9
10
if (!token || token !== "super-secret") {
11
throw new HTTPException(401, { message: "Unauthorized" });
12
}
13
14
await next();
15
});
16
17
app.get("/hello", async (c) => { ... });
18
app.post("/post", async (c) => { ... });
19
20
21
app.get("/secret", async (c) => {
22
return c.json({
23
message: "Secret endpoint!",
24
});
25
});
26
27
export const handler = handle(app);

You should now be able to access the /secret endpoint by providing an Authorization header with the value super-secret:

Terminal window
1
$ http https://<distribution-id>.cloudfront.net/hello Authorization:"super-secret"
2
HTTP/1.1 401 Unauthorized
3
#...
4
Unauthorized

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:

src/index.ts
1
app.get("/hello", async (c) => {
2
return c.json({
3
message: "Hello, World!",
4
headers: Object.fromEntries(c.req.raw.headers.entries()),
5
});
6
});

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:

template.yaml
1
Resources:
2
ApiFunction: # ...
3
4
ApiFunctionPermission: # ...
5
6
Distribution:
7
Type: AWS::CloudFront::Distribution
8
Properties:
9
DistributionConfig:
10
DefaultCacheBehavior:
11
TargetOriginId: # ...
12
CachePolicyId: # ...
13
OriginRequestPolicyId: # ...
14
ViewerProtocolPolicy: # ...
15
AllowedMethods: # ...
16
FunctionAssociations:
17
- EventType: viewer-request
18
FunctionARN: !GetAtt ViewerRequestFunction.FunctionMetadata.FunctionARN
19
Enabled: true
20
Origins: # ...
21
22
OAC: # ...
23
24
ViewerRequestFunction:
25
Type: AWS::CloudFront::Function
26
Properties:
27
Name: ViewerRequestFunction
28
FunctionCode: |
29
async function handler(event) {
30
const headers = event.request.headers;
31
32
if (headers.authorization) {
33
headers['x-authorization'] = { value: headers.authorization.value }
34
}
35
36
return event.request;
37
}
38
AutoPublish: true
39
FunctionConfig:
40
Comment: 'Viewer Request Function to add x-authorization header'
41
Runtime: cloudfront-js-2.0

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:

src/index.ts
1
app.use("/secret", async (c, next) => {
2
const token = c.req.header("X-Authorization");
3
4
if (!token || token !== "super-secret") {
5
throw new HTTPException(401, { message: "Unauthorized" });
6
}
7
8
await next();
9
});

Deploy the application and test the /secret endpoint again:

Terminal window
1
$ http https://<distribution-id>.cloudfront.net/hello Authorization:"super-secret"
2
HTTP/1.1 200 OK
3
# ...
4
{
5
"message": "Secret endpoint!"
6
}

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.

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.

5. Further reading


About the author

I'm Elias Brange, a Cloud Consultant and AWS Community Builder in the Serverless category. I'm on a mission to drive Serverless adoption and help others on their Serverless AWS journey.

Did you find this article helpful? Share it with your friends and colleagues using the buttons below. It could help them too!

Are you looking for more content like this? Follow me on LinkedIn & Twitter !