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:

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:

Events Order
CF invokes Lambda@Edge at four distinct stages: viewer-request, origin-request, origin-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:

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:

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:

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

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.