AWS Lambda URL architecture diagram

Develop lightweight and secure REST APIs with AWS Lambda Function URL and Terraform

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:

  1. The Lambda console
  2. The AWS SDK
  3. The Invoke API
  4. AWS CLI
  5. 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


Posted

in

by