Table of contents
Introduction
When it comes to developing REST APIs on AWS there’s a lot of options. A traditional approach is to take care of the application layer with in-house business logic based on company tech stack preferences for programming languages and frameworks, deployed on compute resources like EC2 or ECS/EKS behind Application Load Balancers.
Another approach in a more distributed world is to offload the routing logic to a managed service such as Amazon API Gateway. This can remove a lot of heavy lifting and logic in your application layer so that developers can focus more on core business logic and modularization.
But sometimes developers only need to expose very simple functionality through an HTTPS endpoint. API Gateway might seem to complex and perhaps more advanced functionality like authentication, routing and throttling is not necessary to get something quickly up and running. For these situations AWS Lambda Function URL could be a feature worth exploring.
What an AWS Lambda Function URL is and how it differs from a regular AWS Lambda Function
Lambda functions can be invoked from a number of AWS services such as DynamoDB Streams, SQS, Kinesis and so on.
Current direct Lambda invocation methods are:
- The Lambda console
- The AWS SDK
- The Invoke API
- AWS CLI
- Function URL HTTPS endpoint
The method Function URL enables a Lambda function to be invoked by an HTTPS endpoint in the format of https://<url-id>.lambda-url.<region>.on.aws
, in addition to the traditional invocation methods.
Access can be controlled with the AuthType parameter combined with resource-based policies.
To only provide access to authenticated users and roles developer can configure AuthType AWS_IAM. Each HTTP request is signed using AWS Signature Version 4 (SigV4).
For unauthenticated access to anyone specify AuthType NONE
. Do take into consideration that Lambda URL itself does not provide throttling or protection capabilities (described in more details below).
Now follows some Terraform code for demonstration purposes. To keep it as simple as possible we deploy an AWS Lambda function based on terraform-aws-modules/terraform-aws-lambda. create_lambda_function_url
is set to true
and authorization_type
to NONE
.
Python source code is intentionally left out at this stage, a fully working code example is referenced in the conclusion.
# AWS Lambda Function with endpoint URL
module "lambda_function_url_demo" {
source = "git::https://github.com/terraform-aws-modules/terraform-aws-lambda.git?ref=f7866811bc1429ce224bf6a35448cb44aa5155e7"
function_name = "lambda-function-url-demo"
description = "Lambda Function URL Demo"
handler = "index.lambda_handler"
runtime = "python3.12"
source_path = "./src/lambda-function-url-demo/index.py"
create_lambda_function_url = true
authorization_type = "NONE"
timeout = 30
cors = {
allow_credentials = true
allow_origins = ["*"]
allow_methods = ["*"]
allow_headers = ["date", "keep-alive"]
expose_headers = ["keep-alive", "date"]
max_age = 60
}
tags = {
Name = "LambdaFunctionUrlDemo"
}
}
output "lambda_function_url_demo_arn" {
value = module.lambda_function_url_demo.lambda_function_arn
description = "Lambda Function URL Demo ARN"
sensitive = false
}
output "lambda_function_url_demo_url" {
value = module.lambda_function_url_demo.lambda_function_url
description = "Lambda Function URL Demo URL"
sensitive = false
}
Result
lambda_function_url_demo_arn = “arn:aws:lambda:eu-west-1:1234567890:function:lambda-function-url-demo”
lambda_function_url_demo_url = “https://ytnvqv4vyv5jdhaj4xumgtgd4e0ggowg.lambda-url.eu-west-1.on.aws/“
The AWS Lambda Function can now be accessed by the endpoint URL output from terraform apply
.
Caveats
Lambda Function URLs are designed to be a simple building block and by itself does not support throttling, API Token authentication and management, Web Application Firewall (WAF) or DDoS protection.
However, this is where Amazon Cloudfront and friends come to assist.
With this approach the perimeter is moved from regional to global endpoints. This means we can benefit from the global scale and network acceleration of Cloudfront which includes AWS Shield Standard for L3/L4 DDoS Protection (you can subscribe to Shield Advanced for L7 protection, automated mitigation and Shield Response Team support). In combination with Web Application Firewall malicious traffic can be mitigated and dropped at the edge to protect our origin from unwanted invocations. This is not only beneficial from a security point of view but it also keeps costs under control.
April 11th 2024 AWS announced support for Origin Access Control for Lambda function URL origins. The Terraform AWS Provider added support for this in v5.46.0 which was released released April 19th 2024.
Changelog: resource/aws_cloudfront_origin_access_control: Add lambda
and mediapackagev2
as valid values for origin_access_control_origin_type
(#34362)
Our Lambda function will now look like this. On line 11 authorization_type is changed from “NONE” to “AWS_IAM”. From line 27 a Lambda permission resource is added which grants the Cloudfront distribution permissions to invoke the function. Lines 35 and throughout defines basic properties of an AWS Cloudfront distribution.
# AWS Lambda Function
module "lambda_function_url_demo" {
source = "git::https://github.com/terraform-aws-modules/terraform-aws-lambda.git?ref=f7866811bc1429ce224bf6a35448cb44aa5155e7"
function_name = "lambda-function-url-demo"
description = "Lambda Function URL Demo"
handler = "index.lambda_handler"
runtime = "python3.12"
source_path = "./src/lambda-function-url-demo/index.py"
create_lambda_function_url = true
authorization_type = "AWS_IAM"
timeout = 30
cors = {
allow_credentials = true
allow_origins = ["*"]
allow_methods = ["*"]
allow_headers = ["date", "keep-alive"]
expose_headers = ["keep-alive", "date"]
max_age = 60
}
tags = {
Name = "LambdaFunctionUrlDemo"
}
}
resource "aws_lambda_permission" "allow_cloudfront" {
statement_id = "AllowCloudFrontServicePrincipal"
action = "lambda:InvokeFunctionUrl"
function_name = module.lambda_function_url_demo.lambda_function_name
principal = "cloudfront.amazonaws.com"
source_arn = aws_cloudfront_distribution.lambda_function_url_demo[0].arn
}
resource "aws_cloudfront_distribution" "lambda_function_url_demo" {
provider = aws.us-east-1
origin {
domain_name = local.lambda_function_url_demo_domain_name
origin_access_control_id = aws_cloudfront_origin_access_control.cloudfront_oac_lambda_url[0].id
origin_id = local.lambda_function_origin_id
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
origin_keepalive_timeout = 5
origin_read_timeout = 30
}
}
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
price_class = "PriceClass_200"
logging_config {
include_cookies = false
bucket = module.cloudfront_logs[0].s3_bucket_bucket_domain_name
prefix = "lambda_function_url_demo"
}
default_cache_behavior {
allowed_methods = ["HEAD", "DELETE", "POST", "GET", "OPTIONS", "PUT", "PATCH"]
cached_methods = ["GET", "HEAD", "OPTIONS"]
target_origin_id = local.lambda_function_origin_id
forwarded_values {
query_string = true
cookies {
forward = "none"
}
}
viewer_protocol_policy = "redirect-to-https"
min_ttl = 0
default_ttl = 0
max_ttl = 86400
compress = true
}
restrictions {
geo_restriction {
restriction_type = "none"
locations = []
}
}
tags = {
Name = "LambdaFunctionUrlDemo"
}
viewer_certificate {
cloudfront_default_certificate = true
minimum_protocol_version = "TLSv1.2_2018"
}
web_acl_id = aws_wafv2_web_acl.lambda_function_url_demo.arn
}
# Amazon Cloudfront distribution OAC
resource "aws_cloudfront_origin_access_control" "cloudfront_oac_lambda_url" {
name = "cloudfront_oac_lambda_url"
description = "Policy for Lambda Function URL origins"
origin_access_control_origin_type = "lambda"
signing_behavior = "always"
signing_protocol = "sigv4"
}
Verification of Origin Access Control
For verification we can observe incoming requests in CloudWatch Logs. For unauthenticated requests directly to the Lambda URL headers x-amz-content-sha256
and x-amz-security-token
are absent.
[INFO] 2024-04-26T13:35:48.597Z 4f4c2779-05cb-439b-ba55-b50415679622 {
"version": "2.0",
"routeKey": "$default",
"rawPath": "/index.html",
"rawQueryString": "input1=YES",
"headers": {
"x-amz-content-sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"x-amzn-tls-version": "TLSv1.2",
"sec-fetch-site": "same-origin",
"x-amz-source-account": "602472554111",
"x-forwarded-port": "443",
"sec-fetch-user": "?1",
"x-amz-security-token": "IQoJb3JpZ2luX2VjEHYaCXVzLWVhc3QtMSJHMEUCIA2gGi8zMAO+5h2fGL65nksRPq51Ks3y3RL5o/SfleOiAiEA6om0TmJJe3uFRO54DCL9DV6/eGxcr+KGEzNiWblc/r0qlwIIv///////////ARAAGgw4NTYzNjkwNTMxODEiDM9OfrD8gawO+6OZ0irrAegKU5LkXCbnheQAeURiaTIUv9BTjdYeGa7p1gjpPw0N51w/2BB/c0eUae11ONdgdcyK6hCLthfyOay96rx7YTbXhvtVSTWkk7Nz6eAAyttffUv+n5c5+CY2M0bLntNkuisImgKh0RRl1rsTYILXOTqqVlT5+Ipd/yZNPtgXf0NsPJNEOsAtWvSZf4PScxYd9Xlk0CvNDUCk1BZ5afUkLXlhO/T1F2Tu0oaYbIwFxLZngmDc+KEMo82HkocD4VG/fmUtp7x2ln9BwCINkLtg7P4REgsJ9WdNUG647hrkJMcBRHaYePATqPhdYtIwttqusQY6jwFudLv6XtcxTs+Yi8NuweNYVXOvyR9N28zX6OasvJh4p3JseSxXr1Ejsgnhcb9rc40uhHlvwqvuNFdgeXiB+xEiVkDV2KtOEULzVd+bO1Nf4va6WTuob1wPG4W73TAUO+xLaDedVcpp+kQQYmr3I3Dh2m31XUiL7unsacWGVi+6DZiVWLoBb6ZJ3zCabR5jOg==",
"via": "2.0 fc5e625db631bc657fc73f189d53fa14.cloudfront.net (CloudFront)",
"x-amzn-tls-cipher-suite": "ECDHE-RSA-AES128-GCM-SHA256",
"sec-ch-ua-mobile": "?0",
"upgrade-insecure-requests": "1",
"host": "ytnvqv4vyv5jdhaj4xumgtgd4e0ggowg.lambda-url.eu-west-1.on.aws",
"sec-fetch-mode": "navigate",
"x-amz-date": "20240426T133548Z",
"x-forwarded-proto": "https",
"x-forwarded-for": "81.166.192.92",
"priority": "u=0, i",
"x-amz-source-arn": "arn:aws:cloudfront::602472554111:distribution/E3RIXKOQDC23IE",
"sec-ch-ua": "\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"",
"x-amzn-trace-id": "Root=1-662badb4-7b849839607a46814fd5c1db",
"sec-ch-ua-platform": "\"Windows\"",
"accept-encoding": "gzip",
"x-amz-cf-id": "TEFeqAECknBq5wwXW6rdwZR6os03LoZJyFZjEbjD-VW7ImAl1BdpYg==",
"user-agent": "Amazon CloudFront",
"sec-fetch-dest": "document"
},
"queryStringParameters": {
"input1": "YES"
},
"requestContext": {
"accountId": "anonymous",
"apiId": "ytnvqv4vyv5jdhaj4xumgtgd4e0ggowg",
"domainName": "ytnvqv4vyv5jdhaj4xumgtgd4e0ggowg.lambda-url.eu-west-1.on.aws",
"domainPrefix": "ytnvqv4vyv5jdhaj4xumgtgd4e0ggowg",
"http": {
"method": "GET",
"path": "/index.html",
"protocol": "HTTP/1.1",
"sourceIp": "64.252.86.126",
"userAgent": "Amazon CloudFront"
},
"requestId": "4f4c2779-05cb-439b-ba55-b50415679622",
"routeKey": "$default",
"stage": "$default",
"time": "26/Apr/2024:13:35:48 +0000",
"timeEpoch": 1714138548591
},
"isBase64Encoded": false
}
Direct non-authorized access to the AWS Lambda Function URL is now denied and our lightweight API can only be accessed through Cloudfront with L3/L4 DDoS protection.
Additional protection with AWS Web Application Firewall
To stop malicious requests from botnets, SQL injection, cross-site scripting (XSS) and so on we associate a Web Application Firewall Access Control List with the Cloudfront distribution. Read more details about AWS WAF, it’s core functionality and aspects to take into consideration at Protect your webapps from malicious traffic with AWS Web Application Firewall.
# Web Application Firewall resources
# Common S3 bucket for WAF logs
resource "aws_cloudwatch_log_group" "waf_cloudwatch_logs" {
#checkov:skip=CKV_AWS_158: KMS encryption unnecessary for this use-case.
count = var.provision_cloudfront == true ? 1 : 0
provider = aws.us-east-1
name = "aws-waf-logs-lambda-function-url-demo"
retention_in_days = 365
}
resource "aws_wafv2_web_acl_logging_configuration" "waf_cloudwatch_logs_config" {
count = var.provision_cloudfront == true ? 1 : 0
provider = aws.us-east-1
log_destination_configs = [aws_cloudwatch_log_group.waf_cloudwatch_logs[0].arn]
resource_arn = aws_wafv2_web_acl.lambda_function_url_demo[0].arn
}
resource "aws_cloudwatch_log_resource_policy" "waf_cloudwatch_logs_resource_policy" {
count = var.provision_cloudfront == true ? 1 : 0
provider = aws.us-east-1
policy_document = data.aws_iam_policy_document.waf_logging[0].json
policy_name = "webacl-policy-waf-lambda-function-url-demo"
}
# Create a Web ACL
resource "aws_wafv2_web_acl" "lambda_function_url_demo" {
#checkov:skip=CKV2_AWS_31: WAF2 logging configuration not necessary for this use-case.
count = var.provision_cloudfront == true ? 1 : 0
provider = aws.us-east-1
name = "lambda_function_url_demo"
description = "Web ACL with managed rule groups for lambda_function_url_demo"
scope = "CLOUDFRONT"
default_action {
allow {}
}
rule {
name = "AWSManagedRulesAmazonIpReputationList"
priority = 1
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesAmazonIpReputationList"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = false
metric_name = "AWSManagedRulesAmazonIpReputationList"
sampled_requests_enabled = false
}
}
rule {
name = "AWSManagedRulesWordPressRuleSet"
priority = 2
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesWordPressRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = false
metric_name = "AWSManagedRulesWordPressRuleSet"
sampled_requests_enabled = false
}
}
rule {
name = "AWSManagedRulesKnownBadInputsRuleSet"
priority = 3
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesKnownBadInputsRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = false
metric_name = "AWSManagedRulesKnownBadInputsRuleSet"
sampled_requests_enabled = false
}
}
rule {
name = "AWSManagedRulesCommonRuleSet"
priority = 4
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = false
metric_name = "AWSManagedRulesCommonRuleSet"
sampled_requests_enabled = false
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "web-acl-lambda-function-url-demo"
sampled_requests_enabled = true
}
}
Full code example
To tie together all the bits and pieces I have developed a sample Terraform module which you can inspect further: https://github.com/haakond/terraform-aws-lambda-function-url . Study the README.md and examples/main.tf for complete documentation on how to get up and running, including Lambda Function code in Python. If you find it useful, feel free to fork and adjust to your needs.
module "lambda_function_url_demo" {
source = "git::https://github.com/haakond/terraform-aws-lambda-function-url.git?ref=e3c72cb76d4a1d5b5b56e4a56a117f0949002a9d"
# As global resources related to Cloudfront and WAF needs to be provisioned in us-east-1, we pass in two different providers.
# Reference: https://developer.hashicorp.com/terraform/language/modules/develop/providers#passing-providers-explicitly
provision_cloudfront = false # Set to false on the first run, set to true on the second run because of circular resources dependencies with Lambda and Cloudfront.
providers = {
aws = aws
aws.us-east-1 = aws.us-east-1
}
}
Conclusion
In this article we explored how AWS Lambda Function URL can be a compelling alternative to self-hosted APIs with Application Load Balancers and Amazon API Gateway for more lightweight and simple REST API use-cases. We looked at how we can secure the solution by adopting Amazon Cloudfront and AWS Web Application Firewall. As a bonus custom domain names are also possible with Amazon Certificate Manager support in Cloudfront. The solution is fully serverless and has no servers or containers to patch or manage.
Further reading
- Amazon CloudFront now supports Origin Access Control (OAC) for Lambda function URL origins
- Amazon CloudFront Developer Guide – Restricting access to an AWS Lambda Function URL origin
- AWS Lambda Developer Guide – Lambda Function URLs
- AWS Lambda Terraform module
- Amazon Cloudfront – Developer Guide – Using AWS WAF protections
- Terraform registry – Resource: aws_cloudfront_origin_access_control
- https://github.com/haakond/terraform-aws-lambda-function-url/