Tagging & Measurement

How to set a persistent UUID cookie using CloudFront and Lamda@Edge

Due to privacy related browser developments such as tracking prevention mechanisms (ITP / ETP), it's getting more difficult to correctly set persistent first-party cookies. Even if there is no data shared with any third-party. Having consistent first-party cookies is important to measure your website user behavior over multiple website visits. When using popular frameworks such as Next.js or Gatsby, your website is prerendered into static HTML files and can be served from an AWS S3 bucket and the CloudFront CDN. Lambda@Edge can be used to run server-side code when a page is requested. In this tutorial we will use a Lambda function to set a persistent first-party (HTTP) cookie in the page response.

  • Using an Lambda@Edge function attached to the viewer-response event within CloudFront, a persistent first-party cookie can be set by modifying response headers and setting an HTTP (server-side) cookie.
  • The cookie contains a UUID. For new users, a fresh UUID is created. For returning users the existing value is used to extend the cookie.
  • Go directly to the code

The UUID enables you to track users over different visits (on your first-party domain). Be aware that you need consent of your users, depending on how you use this UUID. So keep the interests of your users in mind and be transparent / ask for the right permissions when needed.

Modify AWS CloudFront responses using Lamda@Edge

As mentioned, prerendering your website or application to static (HTML) files and serving them out of an AWS S3 bucket in combination with the CloudFront CDN is gaining popularity due to frameworks such as Next.js or Gatsby.

One of the challenges is that you can't run any server-side code directly from your static files. Lambda@Edge solves this problem. It enables you to run code (Python or Node.js) on several events within Cloudfront, so you can modify requests and responses. Lamda function can be used to redirect users to A/B testing variants or country specific content. Modifying responses is also one of the possibilities, including creation of HTTP / server-side cookies. The Lamda@Edge functions are deployed on different locations in edge network, thus will execute as close to the user as possible.

Lamda@Edge functions can be execute on the following locations;

  • Viewer Request
  • Origin Request
  • Origin Response
  • Viewer Response > We will use this event to inspect the viewer request headers and set a cookie in the HTTP response.

first-party-cookie-http-response-lambda-at-edge.png

More information can be found in the AWS documentation

Setting a first-party cookie containing an UUID user identifier directly in the response of the HTML page requested has several benefits:

  • No dependency on client-side Javascript to make an additional request to a service that sets a HTTP cookie.
  • Using such a mechanism mentioned above could create dependency issues; when a service is expecting the first-party UUID cookie but the client-side cookie service has not returned a response yet.
  • You can use the first-party cookie in server-side tag management solutions like Google Tag Manager Server, without the need of this tool to be responsible for creating the cookie (since it can be blocked)

Deploying the Lamda function

See the AWS documentation for a more in depth tutorial.

Steps:

We assume that you already have a website running in AWS Cloudfront.

  1. Create a Lamda function in region us-east-1

It's important that you create the Lamda function in the us-east-1 (N. Virginia) region. When deploying the function to Lamda@Edge it will distribute the functions to all the different locations in the Edge network. Therefore debugging Lamda@Edge function can be difficult, because it generates logs in the region where the Lamda@Edge function is triggered (as close to the end user as possible). So when your located in Germany, most probably the logs can be found in region eu-central-1

  1. Download the index.js and package.json from our GitHub repository.
  2. The Lamda function has some dependencies like the uuid and cookie packages. You will need to install the packages first. Run npm run install in the folder where you placed the package.json and index.js file
  3. Create a ZIP file of index.js, package.json and the /node_modules folder (created after installing the packages).
  4. Upload the ZIP file to AWS and deploy the Lamda Function
  5. Add a trigger to the Lambda Function. Choose "CloudFront" and when configuring this trigger, select the "Viewer Response" event

cloudfront-lambda-viewer-response.PNG

  • It will take some time (seconds / minutes) before the Lambda function is deployed to all Edge locations.
  • When deployed succesfully, you can check if the cookie is set in your browser / developer console (also check that the UUID value remains consistent when navigating to different pages):

first-party-http-cookie.PNG

Some last tips

  • When deploying a new version of your function, make sure you also deploy the function to Lamda@Edge again (step 6). When deploying again, you can select "Use existing CloudFront trigger on this function" in the CloudFront trigger settings.
  • As mentioned, you can debug the logs of a Lamda@Edge function only in the region where the function was invoked.
  • By default, the IAM role attached to your function will not properly create logs in other regions than us-east 1 (see this thread).
  • Solution is to adjust the IAM role of your Lamda function to;
1{
2    "Version": "2012-10-17",
3    "Statement": [
4        {
5            "Effect": "Allow",
6            "Action": "logs:CreateLogGroup",
7            "Resource": "arn:aws:logs:*:*:*"
8        },
9        {
10            "Effect": "Allow",
11            "Action": [
12                "logs:CreateLogStream",
13                "logs:PutLogEvents"
14            ],
15            "Resource": [
16                "arn:aws:logs:*:*:log-group:*:*"
17            ]
18        }
19    ]
20}

The Node.js Lamda function

The AWS Lamda code (view full repository on GitHub). The package.json is available on GitHub.

1// Lambda@Edge function, (re)writing first-party user UUIDs
2// Author: Krisjan Oldekamp / Stacktonic.com
3// Email: krisjan@stacktonic.com
4
5'use strict';
6
7const { v4: uuidv4 } = require('uuid');
8const cookie = require('cookie');
9const cookieName = 'FPD'; // Cookie name containing first-party identifier
10const cookieLifetime = 730; // Cookie expiration of user UUID in days (2 years) 
11
12exports.handler = async (event, context) => {
13    
14    const request = event.Records[0].cf.request;
15    const response = event.Records[0].cf.response;
16
17    try {
18    
19        const requestHeaders = request.headers;
20        const responseHeaders = response.headers;
21
22        // Generate random UUID.
23        let uuid = uuidv4();
24        let setCookie = false;
25
26        if (requestHeaders.cookie) {
27            // Get all cookie information from request headers
28            let cookies = cookie.parse(requestHeaders.cookie[0].value);
29            
30            // Check if user UUID cookie is present and get existing UUID value from requestheaders.
31            if (cookies[cookieName] !== undefined) {
32                uuid = cookies[cookieName];
33            } 
34            
35            // Check for presence of user UUID cookie. If not present, (re)write cookie
36            if (cookies[cookieName] === undefined) {
37                setCookie = true;
38            }
39           
40        } else {
41            setCookie = true;
42        }
43
44        // Set or extend cookies when neccesarry.
45        if (setCookie === true) {
46            let domainTld = (requestHeaders.host[0].value).split(/\./).slice(-2).join('.');
47
48            // Build user cookie string with user UUID.
49            let cookieStringUser = cookie.serialize(cookieName, String(uuid), {
50                domain: '.' + domainTld,
51                path: '/',
52                secure: true,
53                httpOnly: true,
54                sameSite: 'lax',
55                maxAge: 60 * 60 * 24 * cookieLifetime 
56            });
57
58            // Set HTTP cookies in response.
59            responseHeaders['set-cookie'] = [{
60                key: 'set-cookie',
61                value: cookieStringUser
62            }];
63        }
64        return response;
65    } catch(err) {
66        console.log(`Error handling response: ${err}`)
67    }
68    return response;
69};
Did you like this article? Stay hydrated and subscribe to a monthly roundup of our newest articles and tutorials. No spam and you can unsubscribe at any time. You can also checkout my Twitter account for updates.