Navigate back to the homepage

How to implement Lambda@Edge functions in Terraform

David Mattia
October 7th, 2020 · 5 min read

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.

XKCD: Compiling
Lambda@Edge hooks (Image via Amazon AWS)

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}
5
6module "cloudfront-s3-cdn" {
7 source = "cloudposse/cloudfront-s3-cdn/aws"
8 version = "0.34.1"
9
10 name = "edge-acme-example"
11 encryption_enabled = true
12
13 # Caching Settings
14 default_ttl = 300
15 compress = true
16
17 # Website settings
18 website_enabled = true
19 index_document = "index.html"
20 error_document = "index.html"
21}
22
23output s3_bucket {
24 description = "Name of the S3 origin bucket"
25 value = module.cloudfront-s3-cdn.s3_bucket
26}
27
28/** Use remote state through Terraform cloud */
29terraform {
30 backend "remote" {
31 hostname = "app.terraform.io"
32 organization = "transcend-io"
33
34 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 response
3 const response = event.Records[0].cf.response;
4 const headers = response.headers;
5
6 // Set new headers
7 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'}];
12
13 // Return modified response
14 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 = false
4 lambda_arn = module.security_header_lambda.arn
5}]

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; preload
2content-security-policy: default-src 'self' cdnjs.cloudflare.com style-src 'self' 'unsafe-inline';
3x-frame-options: DENY
4x-xss-protection: 1; mode=block
5referrer-policy: same-origin

Beyond the basics

XKCD: Compiling
Building with the right building blocks blocks blockers in the future.(Susan Holt Smith via Unsplash)

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 website
2
3on:
4 push:
5 branches:
6 - master
7
8jobs:
9 deploy:
10 runs-on: ubuntu-latest
11 defaults:
12 run:
13 working-directory: infra/lambda_at_edge_example
14 steps:
15 # Checkout this repo
16 - uses: actions/checkout@master
17
18 # Download a specific terraform version you'd like to use
19 - uses: hashicorp/setup-terraform@v1
20 with:
21 terraform_version: 0.13.3
22 cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}
23 terraform_wrapper: false
24
25 # Ensure you have AWS credentials set up. Your config will likely differ here
26 - name: Configure AWS Credentials
27 uses: aws-actions/configure-aws-credentials@v1
28 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: true
33 role-duration-seconds: 1200
34 aws-region: us-east-1
35
36 # Build the lambda@edge function
37 - uses: actions/setup-node@v2-beta
38 with:
39 node-version: '12'
40 - run: yarn
41 working-directory: src/security_headers
42
43 # Apply the terraform code
44 - run: terraform init
45 - run: terraform validate
46 - run: terraform plan -out planfile
47 - run: terraform apply planfile
48
49 # Upload our website code to our origin S3 bucket
50 - name: Deploy static site to S3 bucket
51 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

XKCD: Compiling
Fancy drinks for a fancy language. (Anthony Delanoix via Unsplash)

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';
7
8/**
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 response
17 const response = event.Records[0].cf.response;
18 const headers = response.headers;
19
20 // Set new headers
21 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'}];
26
27 // Return modified response
28 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 build
2 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_id
3 client_id = var.client_id
4 userpool_region = var.userpool_region
5 ui_subdomain = var.ui_subdomain
6 scopes = join(" ", var.scopes)
7 client_secret_param_name = var.ssm_client_secret_param_name
8}

Then in your code, you can access these values with:

1import { readFileSync } from 'fs';
2
3const 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_secret
3}

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' });
3
4/**
5 * Fetches a decrypted parameter from AWS SSM Parameter Store
6 *
7 * @param name - The name of the parameter to fetch
8 * @returns the decrypted value of the parameter
9 */
10async function fetchSsmParam(name) {
11 const { Parameter } = await ssmClient
12 .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.

More articles from Transcend

Privacy Playbook: Building a best-in-class privacy program without a FAANG engineering budget

You don’t need an Apple- or Google-size budget to implement a user-centric privacy program. Instead, it comes down to smart cross-functional principles and resourcing.

October 1st, 2020 · 7 min read

Watch the recording: privacy_infra() September

Watch back our privacy_infra() virtual event for engineers held on September 24th.

September 29th, 2020 · 1 min read
© 2017 - 2020 Transcend
Link to $https://twitter.com/transcend_ioLink to $https://www.linkedin.com/company/transcend-io/Link to $https://github.com/transcend-io