Dynamic Origin Swapping in AWS CloudFront with Lambda@Edge

I recently needed to add a staging environment to my single page apps (SPA) hosted on S3 and served through an existing CloudFront distribution. My bucket layout looked like this:

├── prod/
│   └── index.html
└── stage/
    └── index.html

Rather than stand up a second distribution, I decided to serve stage traffic from the same CF endpoint—simply by swapping the S3 origin path at runtime. The natural solution is to use two small Lambda@Edge functions plus a custom origin-request policy. In this post, I’ll walk you through the pitfalls I encountered and the final, working solution.

Logging Permissions

Lambda@Edge functions require broader logging permissions because they can be invoked from any AWS region. If you don’t grant these permissions, your function will silently fail whenever it tries to write logs from a region that isn’t explicitly whitelisted in the permissions resource section.

Luckily, AWS provides a built-in IAM policy template just for this scenario: Basic Lambda@Edge permissions (for CloudFront trigger) (thanks fleas). It includes the necessary CF Logs actions and can be attached to your Lambda@Edge execution role. Here’s what the template looks like:

AWS IAM console "Create role" page showing Basic Lambda@Edge permissions template for CloudFront trigger.
New IAM role screen with “Basic Lambda@Edge permissions (for CloudFront trigger)” policy template selected.

This is the contents of this permissions:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:*:*:*"
            ]
        }
    ]
}

And, its trust relationship. Note the edgelambda.amazonaws.com principal:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": [
                    "lambda.amazonaws.com",
                    "edgelambda.amazonaws.com"
                ]
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

Log Prefixed

Lambda@Edge functions always write their CloudWatch logs to cooresponding region, when invoked from other edge locations. As a result, any log group created outside of the “home” region will carry a us-east-1. prefix. For example:

AWS Lambda log group
AWS Lambda log group

Events Order

CF invokes Lambda@Edge at four distinct stages: viewer-requestorigin-requestorigin-response, and viewer-response and each stage sees a different set of headers and request data. I initially assumed that the browser’s original Host header would be available in the origin-request event, but in reality that event only receives the headers CF forwards from the viewer-request stage (after any header rewrites)! Learn how CloudFront events work. Here is the diagram for reference:

Flowchart showing CloudFront cache in the center, arrows labeled Viewer Request and Viewer Response between end user and cache, and arrows labeled Origin Request and Origin Response between cache and origin server, with Lambda@Edge icons at each arrow.
Diagram of the four CloudFront request/response event types—Viewer Request, Origin Request, Origin Response, Viewer Response—and where each invokes a Lambda@Edge function.

Solution

Fortunately, this solution only requires two small Lambda@Edge functions and a custom Origin Request Policy in CF. Note: Lambda@Edge functions must be created in the home region (us-east-1); they won’t work if deployed in any other region.

Function1

Deploy this snippet as a viewer-request Lambda@Edge function. It runs on every incoming request and reliably captures the browser’s original Host header—injecting it as a custom X-Original-Host header for downstream use. This example targets Node.js v20 and uses ES modules (.mjs):

export const handler = async (event) => {
  const request = event.Records[0].cf.request;
  const host = request.headers.host[0].value;

  request.headers['x-original-host'] = [{ key: 'X-Original-Host', value: host }];

  return request;
};

Function2

Deploy this snippet as an origin-request Lambda@Edge function. It executes before CloudFront forwards the request to your origin and adjusts the origin path based on the X-Original-Host header set earlier. It works with both S3 and custom origins. This example targets Node.js v20 and uses ES modules (.mjs):

export const handler = async (event) => {
    const request = event.Records[0].cf.request;
    const host = request.headers['x-original-host'][0].value.toLowerCase();
  
    // choose folder by sub-domain
    let folder = '/prod/browser';
    if (host.startsWith('stage.')) {
      folder = '/stage/browser';
    }
  
    // rewrite the origin path
    if (request.origin && request.origin.custom) {
      request.origin.custom.path = folder;
    } else if (request.origin && request.origin.s3) {
      request.origin.s3.path = folder;
    }
  
    return request;
};

Next, associate your Lambda functions as Lambda@Edge with the CF cache behavior as shown below:

AWS CloudFront “Function associations” settings with Lambda@Edge spa-stage-preserve-host on Viewer Request and spa-stage-redirect on Origin Request.
CloudFront cache-behavior Function associations panel showing two Lambda@Edge functions— spa-stage-preserve-host on Viewer Request and spa-stage-redirect on Origin Request.

Origin Request Policy

Create a custom Origin Request Policy to whitelist specific headers. AWS doesn’t provide a managed policy that includes this header by default, so here’s an example:

AWS CloudFront origin request policy editor showing policy named ForwardOriginalHost and X-Original-Host header selected.
AWS CloudFront “Edit origin request policy” screen with the custom header X-Original-Host added under Headers.

Once created, attach this policy to your distribution’s cache behavior as follows:

AWS CloudFront console “Cache key and origin requests” panel with CachingDisabled policy and ForwardOriginalHost origin request policy selected.
AWS CloudFront “Cache key and origin requests” settings showing the origin request policy “ForwardOriginalHost.”

With these configurations in place, your CF distribution will dynamically load the appropriate S3 origin path based on the subdomain prefix.

Limitations

This solution won’t work when caching is enabled, because origin-request events are only triggered on cache misses.