Here’s the complete solution for every step, including the function code, all CLI commands, and the expected output at each stage.
If you want AWS’s version of the runtime behavior while you read, the CloudFront Functions guide is the official reference.
Why This Works
- Viewer-request functions can change routing before CloudFront decides what to fetch, which is why redirects belong there.
- Viewer-response functions can mutate headers on the way out, which makes them the right place for security headers.
- Publishing to
LIVEand associating the function with the distribution is the operational step that turns working code into real edge behavior.
The Security Headers Function
function handler(event) {
var response = event.response;
var headers = response.headers;
headers['strict-transport-security'] = {
value: 'max-age=63072000; includeSubDomains; preload',
};
headers['x-content-type-options'] = { value: 'nosniff' };
headers['x-frame-options'] = { value: 'DENY' };
headers['referrer-policy'] = { value: 'strict-origin-when-cross-origin' };
Note Setting properties on the existing headers object preserves any headers already present.
return response;
}Create the function
aws cloudfront create-function \
--name security-headers \
--function-config '{"Comment":"Add security headers to all responses","Runtime":"cloudfront-js-2.0"}' \
--function-code 'function handler(event) { var response = event.response; var headers = response.headers; headers["strict-transport-security"] = { value: "max-age=63072000; includeSubDomains; preload" }; headers["x-content-type-options"] = { value: "nosniff" }; headers["x-frame-options"] = { value: "DENY" }; headers["referrer-policy"] = { value: "strict-origin-when-cross-origin" }; return response; }' \
--region us-east-1 \
--output jsonExpected output (abbreviated):
{
"Location": "https://cloudfront.amazonaws.com/2020-05-31/function/security-headers",
"ETag": "ETVPDKIKX0DER",
"FunctionSummary": {
"Name": "security-headers",
"Status": "UNPUBLISHED",
"FunctionConfig": {
"Comment": "Add security headers to all responses",
"Runtime": "cloudfront-js-2.0"
},
"FunctionMetadata": {
"FunctionARN": "arn:aws:cloudfront::123456789012:function/security-headers",
"Stage": "DEVELOPMENT"
}
}
}Save the ETag value—you need it for testing and publishing.
Test the function
aws cloudfront test-function \
--name security-headers \
--if-match ETVPDKIKX0DER \
--event-object '{"version":"1.0","context":{"eventType":"viewer-response"},"viewer":{"ip":"0.0.0.0"},"request":{"method":"GET","uri":"/","querystring":{},"headers":{},"cookies":{}},"response":{"statusCode":200,"statusDescription":"OK","headers":{"content-type":{"value":"text/html"}},"cookies":{}}}' \
--stage DEVELOPMENT \
--region us-east-1 \
--output jsonExpected: The function result includes all four security headers and the original content-type header:
{
"TestResult": {
"FunctionSummary": {
"Name": "security-headers",
"Status": "UNPUBLISHED"
},
"ComputeUtilization": "12",
"FunctionOutput": "{\"response\":{\"statusCode\":200,\"statusDescription\":\"OK\",\"headers\":{\"content-type\":{\"value\":\"text/html\"},\"strict-transport-security\":{\"value\":\"max-age=63072000; includeSubDomains; preload\"},\"x-content-type-options\":{\"value\":\"nosniff\"},\"x-frame-options\":{\"value\":\"DENY\"},\"referrer-policy\":{\"value\":\"strict-origin-when-cross-origin\"}},\"cookies\":{}}}"
}
}The ComputeUtilization value should be low (well under 100). If it approaches 100, the function is too slow.
The Legacy Redirect Function
function handler(event) {
var request = event.request;
if (request.uri === '/old-path') {
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: {
location: { value: '/new-path' },
},
};
}
return request;
}Create the function
aws cloudfront create-function \
--name legacy-redirect \
--function-config '{"Comment":"Redirect /old-path to /new-path","Runtime":"cloudfront-js-2.0"}' \
--function-code 'function handler(event) { var request = event.request; if (request.uri === "/old-path") { return { statusCode: 301, statusDescription: "Moved Permanently", headers: { location: { value: "/new-path" } } }; } return request; }' \
--region us-east-1 \
--output jsonSave the ETag.
Test: request to /old-path
aws cloudfront test-function \
--name legacy-redirect \
--if-match ETVPDKIKX0DER \
--event-object '{"version":"1.0","context":{"eventType":"viewer-request"},"viewer":{"ip":"0.0.0.0"},"request":{"method":"GET","uri":"/old-path","querystring":{},"headers":{"host":{"value":"example.com"}},"cookies":{}}}' \
--stage DEVELOPMENT \
--region us-east-1 \
--output jsonExpected: A 301 response with location: /new-path:
{
"TestResult": {
"FunctionSummary": {
"Name": "legacy-redirect",
"Status": "UNPUBLISHED"
},
"ComputeUtilization": "8",
"FunctionOutput": "{\"response\":{\"statusCode\":301,\"statusDescription\":\"Moved Permanently\",\"headers\":{\"location\":{\"value\":\"/new-path\"}}}}"
}
}Test: request to /about (pass-through)
aws cloudfront test-function \
--name legacy-redirect \
--if-match ETVPDKIKX0DER \
--event-object '{"version":"1.0","context":{"eventType":"viewer-request"},"viewer":{"ip":"0.0.0.0"},"request":{"method":"GET","uri":"/about","querystring":{},"headers":{"host":{"value":"example.com"}},"cookies":{}}}' \
--stage DEVELOPMENT \
--region us-east-1 \
--output jsonExpected: The request passes through unchanged—FunctionOutput contains the original request object with uri: "/about".
Publish Both Functions
aws cloudfront publish-function \
--name security-headers \
--if-match ETVPDKIKX0DER \
--region us-east-1 \
--output jsonaws cloudfront publish-function \
--name legacy-redirect \
--if-match ETVPDKIKX0DER \
--region us-east-1 \
--output jsonReplace the ETag values with the ones you received from the create step (or the most recent operation on each function). Each publish returns a new ETag.
Verify both are LIVE
aws cloudfront describe-function \
--name security-headers \
--stage LIVE \
--region us-east-1 \
--output jsonaws cloudfront describe-function \
--name legacy-redirect \
--stage LIVE \
--region us-east-1 \
--output jsonBoth should show "Stage": "LIVE" in the response.
Associate with the Distribution
Get the current distribution config
aws cloudfront get-distribution-config \
--id E1A2B3C4D5E6F7 \
--region us-east-1 \
--output json > dist-config.jsonEdit the distribution config
Open dist-config.json. Find the DefaultCacheBehavior section and add the FunctionAssociations block:
{
"DefaultCacheBehavior": {
"FunctionAssociations": {
"Quantity": 2,
"Items": [
{
"FunctionARN": "arn:aws:cloudfront::123456789012:function/legacy-redirect",
"EventType": "viewer-request"
},
{
"FunctionARN": "arn:aws:cloudfront::123456789012:function/security-headers",
"EventType": "viewer-response"
}
]
}
}
}The get-distribution-config response wraps the config in a DistributionConfig key and includes an ETag header. When you pass the config to update-distribution, you need the inner DistributionConfig object (not the outer wrapper) and the ETag as the --if-match value.
Update the distribution
aws cloudfront update-distribution \
--id E1A2B3C4D5E6F7 \
--if-match ETAG_FROM_GET \
--distribution-config file://dist-config.json \
--region us-east-1 \
--output jsonWait for deployment
aws cloudfront wait distribution-deployed \
--id E1A2B3C4D5E6F7 \
--region us-east-1This command blocks until the distribution status changes from InProgress to Deployed. It can take a few minutes.
Verify in Production
Check security headers
curl -I https://d111111abcdef8.cloudfront.net/Expected headers in the response:
HTTP/2 200
content-type: text/html
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-originCheck the redirect
curl -I https://d111111abcdef8.cloudfront.net/old-pathExpected:
HTTP/2 301
location: /new-pathCheck that other paths pass through
curl -I https://d111111abcdef8.cloudfront.net/aboutExpected: A normal 200 response (or 404 if the page doesn’t exist) with the security headers present and no redirect.
Stretch Goal: Content Security Policy
Extend the security headers function to include a CSP header:
function handler(event) {
var response = event.response;
var headers = response.headers;
headers['strict-transport-security'] = {
value: 'max-age=63072000; includeSubDomains; preload',
};
headers['x-content-type-options'] = { value: 'nosniff' };
headers['x-frame-options'] = { value: 'DENY' };
headers['referrer-policy'] = { value: 'strict-origin-when-cross-origin' };
headers['content-security-policy'] = {
value:
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'",
};
return response;
}After updating the function code with aws cloudfront update-function, remember to publish it again—the update only changes the DEVELOPMENT stage.
Stretch Goal: Multiple Redirects
Replace the single path check with a redirect map:
var redirects = {
'/old-path': '/new-path',
'/blog/old-post': '/blog/new-post',
'/docs/v1': '/docs',
'/legacy': '/',
};
function handler(event) {
var request = event.request;
var target = redirects[request.uri];
if (target) {
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: {
location: { value: target },
},
};
}
return request;
}This scales to as many redirects as you can fit in 10 KB.