Tried placing Lambda Web Functions behind CloudFront OAC with CloudFormation

Tried placing Lambda Web Functions behind CloudFront OAC with CloudFormation

I tried creating AWS Lambda Web Functions with CloudFormation, configuring CloudFront OAC. Combining IamAuth, OAC, and resource policy, CloudFront returned 200, direct unsigned access returned 403.
2026.10.13

This page has been translated by machine translation. View original

Introduction

On October 12, 2026, simultaneously with the GA of Lambda Web Functions, CloudFront's Origin Access Control (OAC) added support for Lambda Web Functions.

https://aws.amazon.com/jp/about-aws/whats-new/2026/10/cloudfront-lambda-web-functions-origin/

By using OAC, you can deny access that does not go through a specific CloudFront distribution, making it an effective measure against the risks of directly exposing a Web Function.

This time, I had the opportunity to build Lambda Web Functions, CloudFront, and OAC as IaC using CloudFormation, so I will introduce the procedure.

Configuration and Approach

Since the Web Function code can only be passed as a .zip file stored in S3, I split the template into two files and divided the process into 3 steps with code deployment in between.

Step Content Method
1 Create an S3 bucket to store the code CloudFormation
2 Place the code .zip file CLI
3 Create the Web Function, OAC, Distribution, and resource policy CloudFormation

Step 1: Create an S3 Bucket to Store the Code

The bucket that stores the Web Function code must have versioning enabled. Lambda pins the code for each revision using the object version, so versioning is required.

To allow Lambda to read the .zip from the bucket, grant lambda.amazonaws.com s3:GetObject and s3:GetObjectVersion permissions via a bucket policy.

- Sid: AllowLambdaWebFunctionCodeAccess
  Effect: Allow
  Principal:
    Service: lambda.amazonaws.com
  Action:
    - s3:GetObject
    - s3:GetObjectVersion
  Resource: !Sub ${CodeBucket.Arn}/*
  Condition:
    ArnLike:
      aws:SourceArn: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:web-function/*

The aws:SourceArn uses a wildcard to allow read access from all Web Functions in the same account and region. If you want to restrict access to a specific Web Function, specify the function name like web-function/<function-name>/revision/*.

https://docs.aws.amazon.com/lambda/latest/dg/web-functions-code-s3.html

step1-code-bucket.yaml (full content)
AWSTemplateFormatVersion: '2010-09-09'
Description: Step 1 - S3 bucket for Lambda Web Function code (versioning + bucket policy for Lambda)

Resources:
  CodeBucket:
    Type: AWS::S3::Bucket
    Properties:
      VersioningConfiguration:
        Status: Enabled
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true

  CodeBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref CodeBucket
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Sid: AllowLambdaWebFunctionCodeAccess
            Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action:
              - s3:GetObject
              - s3:GetObjectVersion
            Resource: !Sub ${CodeBucket.Arn}/*
            Condition:
              ArnLike:
                aws:SourceArn: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:web-function/*

Outputs:
  CodeBucketName:
    Value: !Ref CodeBucket

Save the template as step1-code-bucket.yaml and create the stack. Once the stack reaches CREATE_COMPLETE, retrieve the bucket name from the CodeBucketName output. This value will be used in Steps 2 and 3.

aws cloudformation create-stack --stack-name hello-web-code --template-body file://step1-code-bucket.yaml
aws cloudformation describe-stacks --stack-name hello-web-code --query 'Stacks[0].Outputs'

Step 2: Place the Code in S3

The application is a simple Hello World that returns JSON. Prepare index.js and package.json. Since index.js uses import syntax (ES modules), specify "type": "module" in package.json.

index.js and package.json

index.js:

// Minimal AWS Lambda Web Function HTTP server.
//
// You can replace this with any code or framework that starts an HTTP server.
import http from 'node:http';

// Lambda sends requests to port 3000. Set LOCAL_PORT for local development.
const port = Number(process.env.LOCAL_PORT ?? 3000);

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'content-type': 'application/json' });
  res.end(JSON.stringify({ message: 'Hello from Lambda Web!', path: req.url }));
});

server.listen(port, () => {
  console.log(`listening on port ${port}`);
});

package.json:

{
  "type": "module",
  "main": "index.js"
}

Compress the files so that index.js is placed at the root of the .zip, then upload it to the bucket from Step 1 as app/hello-web.zip.

zip -r hello-web.zip index.js package.json
aws s3 cp hello-web.zip s3://<CodeBucketName>/app/hello-web.zip

Step 3: Create the Web Function and CloudFront

step3-web-function.yaml (full content)
AWSTemplateFormatVersion: '2010-09-09'
Description: Step 3 - Lambda Web Function (IamAuth) behind CloudFront with OAC

Parameters:
  FunctionName:
    Type: String
    Default: hello-web
  CodeBucket:
    Type: String
    Description: Bucket created in step 1
  CodeKey:
    Type: String
    Default: app/hello-web.zip
    Description: Key of the .zip uploaded in step 2

Resources:
  ExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

  WebFunction:
    Type: AWS::Lambda::WebFunction
    Properties:
      FunctionName: !Ref FunctionName

  Revision:
    Type: AWS::Lambda::WebFunctionRevision
    Properties:
      FunctionName: !Ref WebFunction
      BuildConfig:
        CodeConfig:
          S3Object:
            Bucket: !Ref CodeBucket
            Key: !Ref CodeKey
        RuntimeConfig:
          Runtime: nodejs24.x
      ServiceConfig:
        ExecutionRoleArn: !GetAtt ExecutionRole.Arn
        TimeoutSeconds: 30

  Endpoint:
    Type: AWS::Lambda::WebFunctionEndpoint
    Properties:
      FunctionName: !Ref WebFunction
      EndpointName: main
      EndpointType: HomeRegion
      AuthType: IamAuth
      RevisionWeights:
        - RevisionId: !GetAtt Revision.RevisionId
          Weight: 100

  Oac:
    Type: AWS::CloudFront::OriginAccessControl
    Properties:
      OriginAccessControlConfig:
        Name: !Sub ${FunctionName}-oac
        OriginAccessControlOriginType: lambda
        SigningBehavior: always-amz-auth
        SigningProtocol: sigv4

  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        Comment: !Ref FunctionName
        Origins:
          - Id: web-function
            DomainName: !GetAtt Endpoint.DomainName
            OriginAccessControlId: !GetAtt Oac.Id
            CustomOriginConfig:
              OriginProtocolPolicy: https-only
        DefaultCacheBehavior:
          TargetOriginId: web-function
          ViewerProtocolPolicy: redirect-to-https
          AllowedMethods: [GET, HEAD]
          # Managed-CachingDisabled
          CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
          # Managed-AllViewerExceptHostHeader
          OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac

  ResourcePolicy:
    Type: AWS::Lambda::ResourcePolicy
    Properties:
      ResourceArn: !GetAtt WebFunction.FunctionArn
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Sid: AllowCloudFrontOACAccess
            Effect: Allow
            Principal:
              Service: cloudfront.amazonaws.com
            Action: lambda:InvokeWebFunctionEndpoint
            Resource: !GetAtt Endpoint.EndpointArn
            Condition:
              StringEquals:
                aws:SourceArn: !Sub arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}

Outputs:
  DistributionDomain:
    Value: !GetAtt Distribution.DomainName
  EndpointDomain:
    Value: !GetAtt Endpoint.DomainName

Save the template as step3-web-function.yaml and create the stack. Pass the bucket name obtained in Step 1 to CodeBucket. The CodeKey can be omitted since its default value matches the key placed in Step 2 (app/hello-web.zip). Since an IAM role is being created, specify CAPABILITY_IAM for --capabilities.

aws cloudformation create-stack \
  --stack-name hello-web \
  --template-body file://step3-web-function.yaml \
  --parameters ParameterKey=CodeBucket,ParameterValue=<CodeBucketName> \
  --capabilities CAPABILITY_IAM

Execution Role

The trust policy allows lambda.amazonaws.com to assume the role (sts:AssumeRole), and AWSLambdaBasicExecutionRole is attached to enable writing logs to CloudWatch Logs.

AssumeRolePolicyDocument:
  Version: '2012-10-17'
  Statement:
    - Effect: Allow
      Principal:
        Service: lambda.amazonaws.com
      Action: sts:AssumeRole
ManagedPolicyArns:
  - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

Revision and Endpoint

If VersionId of S3Object is omitted, it is pinned to the latest version of the object at the time the revision is created.

BuildConfig:
  CodeConfig:
    S3Object:
      Bucket: !Ref CodeBucket
      Key: !Ref CodeKey
  RuntimeConfig:
    Runtime: nodejs24.x
ServiceConfig:
  ExecutionRoleArn: !GetAtt ExecutionRole.Arn
  TimeoutSeconds: 30

Since IamAuth is required to use OAC, set the Endpoint's AuthType to IamAuth.

EndpointName: main
EndpointType: HomeRegion
AuthType: IamAuth
RevisionWeights:
  - RevisionId: !GetAtt Revision.RevisionId
    Weight: 100

OAC and Distribution

For an OAC targeting Web Functions, set OriginAccessControlOriginType to lambda and SigningBehavior to always-amz-auth.

OriginAccessControlConfig:
  OriginAccessControlOriginType: lambda
  SigningBehavior: always-amz-auth
  SigningProtocol: sigv4

For the origin request policy, use the managed policy AllViewerExceptHostHeader, which does not forward the Host header to the origin.

Origins:
  - Id: web-function
    DomainName: !GetAtt Endpoint.DomainName
    OriginAccessControlId: !GetAtt Oac.Id
    CustomOriginConfig:
      OriginProtocolPolicy: https-only
DefaultCacheBehavior:
  # Managed-CachingDisabled
  CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
  # Managed-AllViewerExceptHostHeader
  OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-lambda-web.html

Resource Policy

Using AWS::Lambda::ResourcePolicy, grant cloudfront.amazonaws.com permission for lambda:InvokeWebFunctionEndpoint. Specify the ARN of the Web Function to attach the policy to in ResourceArn, and the ARN of the endpoint to allow invocation for in Resource.

ResourceArn: !GetAtt WebFunction.FunctionArn
PolicyDocument:
  Statement:
    - Sid: AllowCloudFrontOACAccess
      Effect: Allow
      Principal:
        Service: cloudfront.amazonaws.com
      Action: lambda:InvokeWebFunctionEndpoint
      Resource: !GetAtt Endpoint.EndpointArn
      Condition:
        StringEquals:
          aws:SourceArn: !Sub arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}

By specifying the ARN of the distribution created in this stack in aws:SourceArn, the caller is restricted to that distribution only.

Verification

Once the stack reaches CREATE_COMPLETE, retrieve the CloudFront domain (DistributionDomain) and the endpoint domain (EndpointDomain) from the outputs.

aws cloudformation describe-stacks --stack-name hello-web --query 'Stacks[0].Outputs'

Sending a GET /hello?x=1 request to the CloudFront standard domain returned a 200 response with a JSON body.

$ curl -sS -i 'https://dxxxxxxxxxxxxx.cloudfront.net/hello?x=1'
HTTP/1.1 200 OK
Content-Type: application/json
X-Cache: Miss from cloudfront

{"message":"Hello from Lambda Web!","path":"/hello?x=1"}

Sending the same request directly to the endpoint domain returned a 403.

$ curl -sS -i 'https://xxxxxxxxxxxxxxxx.xxx.lambda-web.ap-northeast-1.on.aws/hello?x=1'
HTTP/1.1 403 Forbidden

Summary

I confirmed that everything from Lambda Web Functions to CloudFront and OAC configuration can be built using CloudFormation.

To avoid the risks associated with direct exposure, it is recommended to use OAC when publishing Lambda Web Functions.

Although omitted in this verification environment, if you plan to continuously publish Lambda Web Functions, please also consider configuring appropriate cache policies and access logs for CloudFront, and enabling WAF protection as needed.

Share this article

AWSのお困り事はクラスメソッドへ