Background Link to heading

AWS introduced Function URLs for Lambda functions to enable public invocation access over the World Wide Web. Function URLs support IPv4 and IPv6, Cross Origin Resource Sharing (CORS), and IAM for access control. The format of these URLs is https://<url-id>.lambda-url.<region>.on.aws.

You may want to use a custom domain or share an existing domain with a unique path pattern. You may also want to protect function invocation by implementing caching, Origin Shield, or Web Application Firewall (WAF). CloudFront provides these features and can use a Lambda Function URL in an origin declaration.

A major caveat with this approach is function authentication. It appears that the AUTH_TYPE for the function URL must be set to NONE, else CloudFront will return 403 Forbidden. But then the function is publicly accessible for invocation outside of CloudFront by bots and bad actors, increasing error rates and costs.

One approach to this problem is to set a custom HTTP header in CloudFront, coupled with logic in the lambda function to filter out requests that don’t contain the header and expected value. This approach relies on obscurity, and though it saves compute resources if the function fails fast, it does not prevent function invocation.

Another approach is to enable the AWS_IAM AUTH_TYPE in the function URL configuration, then create a Lambda@Edge function bound to the Origin Request event to sign the requests manually.

Today I found an undocumented, native solution to this problem. CloudFront uses Origin Access Control (OAC) policies for restricting access to AWS origins like S3. It does this by signing its requests to the origin, just as the previously mentioned Lambda@Edge function does. For this to work, the OAC policy requires an understanding of the origin type.

The CloudFront Developer Guide, AWS CLI, AWS API, and Terraform AWS Provider documentation only list s3 and mediastore as possible origin types. The AWS Console also does not include controls for adding an OAC to an origin with a custom domain (nor do Lambda functions or function URLs appear in the origin domain dropdown list). However, the CloudFormation documentation shows valid values for OriginAccessControlOriginType as ^(s3|mediastore|lambda|mediapackagev2)$. Why not probe further?

Solution Link to heading

I manually created an Origin Access Control configuration for lambda request signing using the AWS CLI, which produced no errors. The console then correctly showed the OAC with origin type “Lambda function URL”. Of course, editing the OAC or creating a new one in the console does not show Lambda as a possible origin type.

Screenshot of the OAC from the AWS console showing “Lambda function URL” as the Origin type

Further, I was able to successfully associate the OAC to the CloudFront distribution origin configuration both via the CLI and terraform without any errors. Finally, I tested the configuration, ensuring that requests were actually being signed by CloudFront and correctly verified by Lambda. It works flawlessly.

My steps for implementing the native solution are below, both for the CLI and Terraform.

Using the CLI Link to heading

Create a custom Origin Access Control configuration using the AWS CLI. The important part here is using the undocumented property OriginAccessControlOriginType=lambda.

$ aws cloudfront create-origin-access-control \
  --origin-access-control-config "Name=oac,SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=lambda"

Note the OAC ID.

Download your CloudFront distribution configuration (replace the id value with the ID of your distribution).

$ aws cloudfront get-distribution-config --id XXXXXXXXXXXXX --output yaml > dist-config.yaml

You’ll need to edit dist-config.yaml to make a few changes:

  1. Replace the key ETag with IfMatch, keeping the value the same
  2. Under the origin item for your lambda function URL, remove OriginAccessIdentity if it exists
  3. Under the origin item for your lambda function URL, set OriginAccessControlId to the OAC ID from the previous step

Then update the distribution configuration.

$ aws cloudfront update-distribution --id XXXXXXXXXXXXX --cli-input-yaml file://dist-config.yaml

You’ll need a lambda permission for CloudFront to invoke the function when IAM access control is enabled. Note that the action is lambda:InvokeFunctionUrl, not lambda:InvokeFunction.

$ aws lambda add-permission \
  --function-name "your function name" \
  --statement-id "cloudfront-access" \
  --action "lambda:InvokeFunctionUrl"  \
  --principal "cloudfront.amazonaws.com" \
  --source-arn "arn of your cloudfront distribution"

Then you can enable AWS_IAM authentication in your lambda function.

$ aws lambda update-function-url-config --function-name "your function name" --auth-type AWS_IAM

Using Terraform Link to heading

The Terraform AWS provider will cause terraform apply to fail if you use lambda as the value for the origin_access_control_origin_type argument in the aws_cloudfront_origin_access_control resource. I opened an issue to add this undocumented value to the resource schema. In the meantime, you can create the OAC manually via the CLI and then hard code the OAC ID in your CloudFront distribution configuration.

$ aws cloudfront create-origin-access-control \
  --origin-access-control-config "Name=oac,SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=lambda"
# Note the OAC ID
#
# Lambda
#

resource "aws_lambda_function" "fn" {
  # ...
}

resource "aws_lambda_function_url" "url" {
  function_name      = aws_lambda_function.fn.function_name
  authorization_type = "AWS_IAM"
}

resource "aws_lambda_permission" "from_cloudfront" {
  function_name = aws_lambda_function.fn.function_name
  principal     = "cloudfront.amazonaws.com"
  action        = "lambda:InvokeFunctionUrl"
  source_arn    = aws_cloudfront_distribution.dist.arn
}

#
# CloudFront
#

data "aws_cloudfront_cache_policy" "disabled" {
  name = "Managed-CachingDisabled"
}

# TODO: Wait for `lambda` origin type support
# https://github.com/hashicorp/terraform-provider-aws/issues/36660
# resource "aws_cloudfront_origin_access_control" "oac_lambda" {
#   name                              = "oac-lambda"
#   description                       = "OAC Policy for lambda function"
#   origin_access_control_origin_type = "lambda"
#   signing_behavior                  = "always"
#   signing_protocol                  = "sigv4"
# }

resource "aws_cloudfront_distribution" "dist" {
  # ...

  origin {
    # ... for the default origin
  }

  origin {
    origin_id = "lambda"

    # origin_access_control_id = aws_cloudfront_origin_access_control.oac_lambda.id
    origin_access_control_id = "XXXXXXXXXXXXX" # TODO: see above. hard code for now

    # Named capture group for extracting domain from the function URL
    domain_name = regex("(?:(?P<pr>[^:/?#]+):)?(?://(?P<d>[^/?#]*))?(?P<p>[^?#]*)(?:\\?(?P<q>[^#]*))?(?:#(?P<f>.*))?", aws_lambda_function.fn.function_url).d

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only" # required for lambda origin
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  default_cache_behavior {
    # ...
  }

  # Lambda path
  ordered_cache_behavior {
    path_pattern           = "/api"
    allowed_methods        = ["GET", "HEAD", "POST", "DELETE", "OPTIONS", "PUT", "PATCH"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = "lambda"
    cache_policy_id        = data.aws_cloudfront_cache_policy.disabled.id
    compress               = true
    viewer_protocol_policy = "https-only"
    min_ttl                = 0
  }
}

Troubleshooting Link to heading

An IAM authentication failure will result in a 403 Forbidden error to the client with response header x-amzn-ErrorType=AccessDeniedException. You should not get this error when accessing the function through CloudFront, but you should get this error when accessing the function URL directly. If you don’t, ensure the lambda function URL AUTH_TYPE is set to AWS_IAM.

To troubleshoot problems with signing, change the function URL AUTH_TYPE back to NONE and log the request headers to the console in your lambda script. A signed request from CloudFront will include the Authorization, X-Amz-Security-Token, and X-Amz-Date headers. If you’re getting a forbidden error but your requests include these headers, make sure you specified a lambda permission granting your CloudFront distribution access to lambda:InvokeFunctionUrl (not lambda:InvokeFunction) on your lambda function resource (not the function URL resource).

Conclusion Link to heading

CloudFront supports native request signing to Lambda Function URL resources. I don’t know why this feature isn’t documented. When implemented, access to Function URLs can be restricted to CloudFront requests, enforcing security and caching policy while avoiding malicious invocations directly to the function URL.