AWS CloudFront is a global Content Delivery Network (CDN) that gives developers extensive controls over their frontends. CloudFront comes with many configuration options for controlling caching, dynamic origins, geographic restrictions, and much more. But the greatest control that CloudFront offers comes from a service called Lambda@Edge.
With Lambda@Edge, you can run serverless functions through AWS Lambda on any of four event hooks that happen during a request for our origin content. These functions run at the edge locations of the CDN, meaning that Lambda@Edge promises a way to have a multi-region active-active backend where you only pay for the compute time that you use.

That sounds wonderful, but implementing these functions in practice can be challenging and error-prone. Terraform makes provisioning most cloud infrastructure a breeze, but Lambda functions have a number of challenges with them.
In a typical modern codebase, a tool like Terraform will manage infrastructure only, while your CI/CD pipeline deploys the application code. But Lambda functions blur those lines, being a serverless service that integrates with other pieces of infrastructure, while itself containing application code.
This blog post aims to show that not only are making Lambda@Edge functions in Terraform possible, but that it can actually be a pleasant experience.
At Transcend, these functions have enabled us to create consistently secure frontends without needing to update our frontend application code at all, which has been a wonderful experience.
Pro tip: If you want to host a frontend in AWS, CloudFront with an S3 origin is the recommended method. And if you want to get started with setting up a frontend with Terraform, check out our recent blog post on how to do so.
Getting started
For the purposes of this tutorial, let’s assume we have a very basic frontend that we’d like to deploy. And as a security-conscious organization, we’d like to add a Lambda@Edge function to ensure we are properly setting security headers (such as a Content Security Policy) in our responses.
To begin with, let’s start with a Terraform file that declares a frontend on AWS using S3 and Cloudfront. For an overview of how this works, check out our blog post here
1provider "aws" {2 version = "~> 2.0"3 region = "us-east-1"4}56module "cloudfront-s3-cdn" {7 source = "cloudposse/cloudfront-s3-cdn/aws"8 version = "0.34.1"910 name = "edge-acme-example"11 encryption_enabled = true1213 # Caching Settings14 default_ttl = 30015 compress = true1617 # Website settings18 website_enabled = true19 index_document = "index.html"20 error_document = "index.html"21}2223output s3_bucket {24 description = "Name of the S3 origin bucket"25 value = module.cloudfront-s3-cdn.s3_bucket26}2728/** Use remote state through Terraform cloud */29terraform {30 backend "remote" {31 hostname = "app.terraform.io"32 organization = "transcend-io"3334 workspaces {35 name = "edge-blog-frontend"36 }37 }38}
This sets up a complete frontend, with an S3 bucket origin, and a CloudFront distribution on top of it like we have available here. To create a Lambda function, we just need to point a module towards some application code we’ve written.
In this example, we’ve written a simple function that updates a response to include the security headers we want:
1exports.handler = (event, context, callback) => {2 // Get contents of response3 const response = event.Records[0].cf.response;4 const headers = response.headers;56 // Set new headers7 headers['strict-transport-security'] = [{key: 'Strict-Transport-Security', value: 'max-age= 63072000; includeSubdomains; preload'}];8 headers['content-security-policy'] = [{key: 'Content-Security-Policy', value: "default-src 'self' cdnjs.cloudflare.com style-src 'self' 'unsafe-inline';"}];9 headers['x-frame-options'] = [{key: 'X-Frame-Options', value: 'DENY'}];10 headers['x-xss-protection'] = [{key: 'X-XSS-Protection', value: '1; mode=block'}];11 headers['referrer-policy'] = [{key: 'Referrer-Policy', value: 'same-origin'}];1213 // Return modified response14 callback(null, response);15};
And then to create the Lambda function, we add the Terraform module:
1module "security_header_lambda" {2 source = "transcend-io/lambda-at-edge/aws"3 version = "0.0.2"4 name = "security_headers"5 description = "Adds security headers to the response"6 runtime = "nodejs12.x"7 lambda_code_source_dir = "${path.module}/../../src/security_headers"8}
The last step is to connect that Lambda function to one of the four CloudFront events. To do so, we update the CloudFront module to add the field:
1lambda_function_association = [{2 event_type = "origin-response"3 include_body = false4 lambda_arn = module.security_header_lambda.arn5}]
You can use up to four functions per CloudFront cache behavior, one for each of the hooks.
To validate that response headers are updated, we can run:
1curl -D - https://d3h5cy7by9ugu9.cloudfront.net/ -o /dev/null
And we will see that the headers include our new security settings:
1strict-transport-security: max-age= 63072000; includeSubdomains; preload2content-security-policy: default-src 'self' cdnjs.cloudflare.com style-src 'self' 'unsafe-inline';3x-frame-options: DENY4x-xss-protection: 1; mode=block5referrer-policy: same-origin
Beyond the basics

In the remaining sections, we will expand our Terraform code to handle some common real-world use cases when dealing with Lambda@Edge functions.
Setting up a CI/CD pipeline
As mentioned before, Lambda@Edge blurs some lines between application code and infrastructure. Because of this, your CI pipelines will need to do application code tasks, like building your code from source, while your pipeline will also need to deploy your infrastructure with Terraform commands.
As an example, here is an example of a Github Action workflow that can build and deploy a Lambda@Edge function from scratch using our module we made above:
1name: Deploy a Lambda@Edge function and website23on:4 push:5 branches:6 - master78jobs:9 deploy:10 runs-on: ubuntu-latest11 defaults:12 run:13 working-directory: infra/lambda_at_edge_example14 steps:15 # Checkout this repo16 - uses: actions/checkout@master1718 # Download a specific terraform version you'd like to use19 - uses: hashicorp/setup-terraform@v120 with:21 terraform_version: 0.13.322 cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}23 terraform_wrapper: false2425 # Ensure you have AWS credentials set up. Your config will likely differ here26 - name: Configure AWS Credentials27 uses: aws-actions/configure-aws-credentials@v128 with:29 aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}30 aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}31 role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}32 role-skip-session-tagging: true33 role-duration-seconds: 120034 aws-region: us-east-13536 # Build the lambda@edge function37 - uses: actions/setup-node@v2-beta38 with:39 node-version: '12'40 - run: yarn41 working-directory: src/security_headers4243 # Apply the terraform code44 - run: terraform init45 - run: terraform validate46 - run: terraform plan -out planfile47 - run: terraform apply planfile4849 # Upload our website code to our origin S3 bucket50 - name: Deploy static site to S3 bucket51 run: aws s3 sync ../../public s3://$(terraform output s3_bucket) --delete
There’s nothing terribly fancy in this action. It checks out the repository, downloads dependencies, sets up credentials, builds the code, deploys the terraform to create the Lambda function and CloudFront distribution, then deploys the static site to the CloudFront origin.
Even if you don’t use Github actions, these steps should be straightforward enough to port to your CI/CD platform of choice. For a complete example of a working repo with this pipeline set up, check out https://github.com/transcend-io/blog-frontend.
Using Typescript

When you upload a Lambda function artifact, the artifact should contain runnable Javascript. That means that the Lambda runtime environment will not compile down your code, nor will it install node_modules. It’s up to you to package your code so it is ready to run.
First, let’s add a tsconfig.json
to our package. For this example, I’ll keep things with most of the defaults from running tsc --init
:
1{2 "compilerOptions": {3 "target": "ES2017",4 "module": "commonjs",5 "strict": true,6 "noImplicitAny": true,7 "esModuleInterop": true,8 "forceConsistentCasingInFileNames": true ,9 "outDir": "dist",10 }11}
Next, we will update our package.json
to install a specific typescript version and to know how to build our code with tsc
:
1{2 "author": "Transcend Inc.",3 "name": "@main/security_headers",4 "license": "UNLICENSED",5 "main": "index.js",6 "dependencies": {},7 "scripts": {8 "build": "tsc --build"9 },10 "devDependencies": {11 "@types/aws-lambda": "^8.10.62",12 "typescript": "^4.0.3"13 }14}
Notice that we included a dev dependency on some AWS Lambda types so that we can strongly type our code as we write it. Outside of adding types, our JavaScript code stays mostly the same. Here’s the new index.ts
:
1import {2 CloudFrontResponseEvent,3 CloudFrontResponseHandler,4 Context,5 CloudFrontResponseCallback,6} from 'aws-lambda';78/**9 * Adds security headers to a response.10 */11export const handler: CloudFrontResponseHandler = (12 event: CloudFrontResponseEvent,13 _: Context,14 callback: CloudFrontResponseCallback,15): void => {16 // Get contents of response17 const response = event.Records[0].cf.response;18 const headers = response.headers;1920 // Set new headers21 headers['strict-transport-security'] = [{key: 'Strict-Transport-Security', value: 'max-age= 63072000; includeSubdomains; preload'}];22 headers['content-security-policy'] = [{key: 'Content-Security-Policy', value: "default-src 'self' cdnjs.cloudflare.com style-src 'self' 'unsafe-inline';"}];23 headers['x-frame-options'] = [{key: 'X-Frame-Options', value: 'DENY'}];24 headers['x-xss-protection'] = [{key: 'X-XSS-Protection', value: '1; mode=block'}];25 headers['referrer-policy'] = [{key: 'Referrer-Policy', value: 'same-origin'}];2627 // Return modified response28 callback(null, response);29};
Lastly, we need to update our Terraform code to pull from the dist
directory that our compiler outputs code to:
1lambda_code_source_dir = "${path.module}/../../src/security_headers/dist"
The final step is to update our CI/CD script to compile our typescript down, right after the yarn
step that installs node dependencies:
1- run: yarn build2 working-directory: src/security_header
And with those small changes, we are done!
A workaround for not having environment variable support
While Lambda@Edge runs on AWS Lambda, there are quite a few restrictions placed on edge functions that normal functions aren’t subject to.
The one that seems to get in the way most often is that environment variables are not supported. This can be problematic, as sometimes you want to be able to write a single Lambda typescript file that works across multiple different frontends.
A common workaround is to upload a file with configuration values to your Lambda function that can be read in by the application code.
Uploading a static file is easy enough, but in the world of Infrastructure as Code, you will likely want this file to be dynamic. Say you are building a Lambda@Edge function to handle user authentication. If you are authenticating with Amazon Cognito, you will likely want to pass in some information about who should be allowed to login to your site. And as this information likely comes from another Terraform resource or variable, you will want to dynamically create the file based on the terraform outputs.
With our Lambda@Edge module, this is as easy as specifying a plaintext_params
field:
1plaintext_params = {2 userpool_id = var.userpool_id3 client_id = var.client_id4 userpool_region = var.userpool_region5 ui_subdomain = var.ui_subdomain6 scopes = join(" ", var.scopes)7 client_secret_param_name = var.ssm_client_secret_param_name8}
Then in your code, you can access these values with:
1import { readFileSync } from 'fs';23const config = JSON.parse(readFileSync('./config.json'));
And just like that, config.userpool_id
will have the value you set in Terraform. And because the config
variable is set outside of your lambda handler, it’s value will be cached across Lambda runs, meaning there should be no performance hit for executions on an already warm Lambda server.
Securely reference secret values
The above section shows us how to add configuration values to our functions, but what if these config values are sensitive? API keys or other secret information should not be set in plaintext where a developer or attacker may be able to find them. Instead, your secrets should be protected in a secret store like the AWS Systems Manager (SSM) Parameter Store or Hashicorp Vault where you can finely control and audit access.
The Lambda@Edge module we are using will allow you to create SSM parameters easily, and will also automatically update your function’s IAM Role so that it has permissions to access those secret values.
To add a secret in Terraform, add a ssm_params
field like:
1ssm_params = {2 edge_client_secret = var.client_secret3}
Then in your code, you can enable accessing secrets with the function:
1// All Lambda@Edge functions must reside in us-east-1.2const ssmClient = new SSM({ region: 'us-east-1' });34/**5 * Fetches a decrypted parameter from AWS SSM Parameter Store6 *7 * @param name - The name of the parameter to fetch8 * @returns the decrypted value of the parameter9 */10async function fetchSsmParam(name) {11 const { Parameter } = await ssmClient12 .getParameter({13 Name: name,14 WithDecryption: true,15 })16 .promise();17 return Parameter.Value;18}
Later, you could access the secret with:
1await fetchSsmParam("edge_client_secret")
And just like that, you have a secure way of adding dynamic secrets to your Lambda@Edge functions. You can follow a similar process for Hashicorp Vault, but will need to first authenticate with the AWS IAM authentication method.
Summing up
Lambda@Edge adds nearly unlimited power to controlling your frontend behaviors. It can be used for authentication purposes, A/B testing, DDoS protection, creating dynamic websites, and much more.
It took a fair amount of research and troubleshooting to make this process easy, but hopefully our module makes it easier for you to get started.
If you want to get started with setting up a frontend with Terraform, check out our recent blog post on how to do so.