DEV Community

Building an A/B Testing System with CloudFront Functions using AWS CDK

Prerequisites

Why This is Needed

Amazon CloudFront + Amazon S3 is an essential configuration for delivering frontend content on AWS.
Additionally, A/B testing is an indispensable method for improving web services.

However, implementing A/B testing with traditional configurations requires implementation in frontend code or APIs.
This adds complexity to application logic since it requires modifications to the application layer.

In April 2025, there was an update that enabled origin switching with CloudFront Functions.


This time, we'll explore how to easily implement A/B testing by switching between S3 buckets using the selectRequestOriginById() method in CloudFront Functions.

Benefits of Using CloudFront Functions

  • High-speed processing at the edge: Instant decisions at locations closest to users
  • Simple implementation: Concise JavaScript code that's easy to deploy
  • Infrastructure separation: Separates application code from A/B testing logic, improving maintainability

This approach enables efficient A/B testing without sacrificing performance.

Required Knowledge

  • Basic understanding of AWS CDK (Cloud Development Kit)
  • Basic knowledge of TypeScript/JavaScript
  • Basic concepts of AWS CloudFront and S3
  • Understanding of A/B testing concepts

Required Environment

  • Node.js (v18 or higher recommended)
  • AWS CLI configured
  • AWS CDK CLI (npm install -g aws-cdk)
  • TypeScript environment

AWS Services Used

  • Amazon S3: Static website hosting (two buckets)
  • Amazon CloudFront: CDN distribution and A/B testing logic
  • CloudFront Function: Request routing control
  • Origin Access Control (OAC): Secure access control to S3 buckets

Setup

1. Project Initialization

# Create CDK project
mkdir cloudfront-ab-testing
cd cloudfront-ab-testing
cdk init app --language typescript
Enter fullscreen mode Exit fullscreen mode

2. Project Structure Setup

cloudfront-ab-testing/
├── lib/
│   ├── cloudfront-ab-testing.ts      # Main stack
│   └── ab-test-function.js           # CloudFront Function
├── assets/
│   ├── site-a/
│   │   └── index.html               # Variant A
│   └── site-b/
│       └── index.html               # Variant B
└── package.json
Enter fullscreen mode Exit fullscreen mode

Implementation Steps

1. CloudFront Function Implementation

Create a CloudFront Function that handles A/B testing logic:

// lib/ab-test-function.js
import cf from 'cloudfront';

function handler(event) {
    var request = event.request;
    var headers = request.headers;

    // Check X-AB-Test header
    if (headers['x-ab-test'] && headers['x-ab-test'].value === 'variant-b') {
        // Select Origin B
        cf.selectRequestOriginById('BUCKET_B_ORIGIN_ID');
    } else {
        // Select Origin A by default (variant-a or no header)
        cf.selectRequestOriginById('BUCKET_A_ORIGIN_ID');
    }

    return request;
}
Enter fullscreen mode Exit fullscreen mode

2. S3 Bucket Creation

Create two S3 buckets to host different content:

// S3 Bucket A (for variant-a)
const bucketA = new s3.Bucket(this, 'BucketA', {
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});

// S3 Bucket B (for variant-b)
const bucketB = new s3.Bucket(this, 'BucketB', {
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});
Enter fullscreen mode Exit fullscreen mode

3. Origin Access Control (OAC) Configuration

Configure OAC to ensure secure access to S3 buckets:

const originAccessControl = new cloudfront.CfnOriginAccessControl(this, 'OriginAccessControl', {
  originAccessControlConfig: {
    name: 'ABTestOAC',
    originAccessControlOriginType: 's3',
    signingBehavior: 'always',
    signingProtocol: 'sigv4',
    description: 'OAC for A/B testing CloudFront distribution',
  },
});
Enter fullscreen mode Exit fullscreen mode

4. Custom Cache Policy Creation

Create a custom policy that includes A/B test headers in the cache key:

const customCachePolicy = new cloudfront.CfnCachePolicy(this, 'ABTestCachePolicy', {
  cachePolicyConfig: {
    name: 'ABTestCachePolicy',
    comment: 'Cache policy that includes x-ab-test header as cache key',
    defaultTtl: 86400, // 1 day
    maxTtl: 31536000, // 1 year
    minTtl: 0,
    parametersInCacheKeyAndForwardedToOrigin: {
      enableAcceptEncodingGzip: true,
      enableAcceptEncodingBrotli: true,
      headersConfig: {
        headerBehavior: 'whitelist',
        headers: ['x-ab-test'],
      },
      queryStringsConfig: {
        queryStringBehavior: 'none',
      },
      cookiesConfig: {
        cookieBehavior: 'none',
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

5. CloudFront Distribution Configuration

Configure distribution with multiple origins and CloudFront Function:

const distribution = new cloudfront.CfnDistribution(this, 'CloudFrontDistribution', {
  distributionConfig: {
    enabled: true,
    priceClass: 'PriceClass_100',
    origins: [
      {
        id: bucketAOriginId,
        domainName: bucketA.bucketDomainName,
        s3OriginConfig: {
          originAccessIdentity: '',
        },
        originAccessControlId: originAccessControl.attrId,
      },
      {
        id: bucketBOriginId,
        domainName: bucketB.bucketDomainName,
        s3OriginConfig: {
          originAccessIdentity: '',
        },
        originAccessControlId: originAccessControl.attrId,
      },
    ],
    defaultCacheBehavior: {
      targetOriginId: bucketAOriginId,
      viewerProtocolPolicy: 'redirect-to-https',
      cachePolicyId: customCachePolicy.ref,
      compress: true,
      functionAssociations: [
        {
          eventType: 'viewer-request',
          functionArn: abTestFunction.functionArn,
        },
      ],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

6. S3 Bucket Policy Configuration

Configure access permissions from CloudFront using OAC:

const bucketAPolicyStatement = new iam.PolicyStatement({
  actions: ['s3:GetObject'],
  resources: [bucketA.arnForObjects('*')],
  principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
  conditions: {
    StringEquals: {
      'AWS:SourceArn': `arn:aws:cloudfront::${this.account}:distribution/${distribution.ref}`,
    },
  },
});

bucketA.addToResourcePolicy(bucketAPolicyStatement);
Enter fullscreen mode Exit fullscreen mode

7. Content Deployment

Deploy static files to S3 buckets:

// Upload Site A files to BucketA
new s3deploy.BucketDeployment(this, 'DeploySiteA', {
  sources: [s3deploy.Source.asset('./assets/site-a')],
  destinationBucket: bucketA,
});

// Upload Site B files to BucketB
new s3deploy.BucketDeployment(this, 'DeploySiteB', {
  sources: [s3deploy.Source.asset('./assets/site-b')],
  destinationBucket: bucketB,
});
Enter fullscreen mode Exit fullscreen mode

8. Complete Stack Implementation

The complete main stack implementation, integrating all the previous steps, is as

follows:
import * as cdk from 'aws-cdk-lib/core';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';
import * as fs from 'fs';
import * as path from 'path';

export class CloudfrontAbTestingStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // S3 Bucket A (for variant-a)
    const bucketA = new s3.Bucket(this, 'BucketA', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // S3 Bucket B (for variant-b)
    const bucketB = new s3.Bucket(this, 'BucketB', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // Origin Access Control (OAC) - New recommended approach
    const originAccessControl = new cloudfront.CfnOriginAccessControl(this, 'OriginAccessControl', {
      originAccessControlConfig: {
        name: 'ABTestOAC',
        originAccessControlOriginType: 's3',
        signingBehavior: 'always',
        signingProtocol: 'sigv4',
        description: 'OAC for A/B testing CloudFront distribution',
      },
    });

    // Define Origin IDs as constants
    const bucketAOriginId = 'BucketAOrigin';
    const bucketBOriginId = 'BucketBOrigin';

    // CloudFront Function for A/B testing
    // Load Function code from external file
    const functionCodePath = path.join(__dirname, 'ab-test-function.js');
    let functionCode = fs.readFileSync(functionCodePath, 'utf8');

    // Replace placeholders with actual values
    functionCode = functionCode
      .replaceAll('BUCKET_A_ORIGIN_ID', bucketAOriginId)
      .replaceAll('BUCKET_B_ORIGIN_ID', bucketBOriginId);

    const abTestFunction = new cloudfront.Function(this, 'ABTestFunction', {
      code: cloudfront.FunctionCode.fromInline(functionCode),
      comment: 'A/B testing function to route requests based on X-AB-Test header',
      runtime: cloudfront.FunctionRuntime.JS_2_0, // Specify JavaScript Runtime 2.0
    });

    // Custom cache policy (include x-ab-test header in cache key)
    const customCachePolicy = new cloudfront.CfnCachePolicy(this, 'ABTestCachePolicy', {
      cachePolicyConfig: {
        name: 'ABTestCachePolicy',
        comment: 'Cache policy that includes x-ab-test header as cache key',
        defaultTtl: 86400, // 1 day
        maxTtl: 31536000, // 1 year
        minTtl: 0,
        parametersInCacheKeyAndForwardedToOrigin: {
          enableAcceptEncodingGzip: true,
          enableAcceptEncodingBrotli: true,
          headersConfig: {
            headerBehavior: 'whitelist',
            headers: ['x-ab-test'],
          },
          queryStringsConfig: {
            queryStringBehavior: 'none',
          },
          cookiesConfig: {
            cookieBehavior: 'none',
          },
        },
      },
    });

    // CloudFront Distribution (using low-level API for precise control)
    const distribution = new cloudfront.CfnDistribution(this, 'CloudFrontDistribution', {
      distributionConfig: {
        enabled: true,
        priceClass: 'PriceClass_100',
        origins: [
          {
            id: bucketAOriginId,
            domainName: bucketA.bucketDomainName,
            s3OriginConfig: {
              originAccessIdentity: '', // Empty string when using OAC
            },
            originAccessControlId: originAccessControl.attrId,
          },
          {
            id: bucketBOriginId,
            domainName: bucketB.bucketDomainName,
            s3OriginConfig: {
              originAccessIdentity: '', // Empty string when using OAC
            },
            originAccessControlId: originAccessControl.attrId,
          },
        ],
        defaultCacheBehavior: {
          targetOriginId: bucketAOriginId,
          viewerProtocolPolicy: 'redirect-to-https',
          cachePolicyId: customCachePolicy.ref, // Use custom cache policy
          compress: true,
          functionAssociations: [
            {
              eventType: 'viewer-request',
              functionArn: abTestFunction.functionArn,
            },
          ],
        },
      },
    });

    // Bucket A policy (for OAC) - Set after Distribution creation
    const bucketAPolicyStatement = new iam.PolicyStatement({
      actions: ['s3:GetObject'],
      resources: [bucketA.arnForObjects('*')],
      principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
      conditions: {
        StringEquals: {
          'AWS:SourceArn': `arn:aws:cloudfront::${this.account}:distribution/${distribution.ref}`,
        },
      },
    });

    bucketA.addToResourcePolicy(bucketAPolicyStatement);

    // Bucket B policy (for OAC) - Set after Distribution creation
    const bucketBPolicyStatement = new iam.PolicyStatement({
      actions: ['s3:GetObject'],
      resources: [bucketB.arnForObjects('*')],
      principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
      conditions: {
        StringEquals: {
          'AWS:SourceArn': `arn:aws:cloudfront::${this.account}:distribution/${distribution.ref}`,
        },
      },
    });

    bucketB.addToResourcePolicy(bucketBPolicyStatement);

    // Deploy HTML files to S3 buckets
    // Upload Site A files to BucketA
    new s3deploy.BucketDeployment(this, 'DeploySiteA', {
      sources: [s3deploy.Source.asset('./assets/site-a')],
      destinationBucket: bucketA,
    });

    // Upload Site B files to BucketB
    new s3deploy.BucketDeployment(this, 'DeploySiteB', {
      sources: [s3deploy.Source.asset('./assets/site-b')],
      destinationBucket: bucketB,
    });

    // Stack outputs
    new cdk.CfnOutput(this, 'CloudFrontDomainName', {
      value: distribution.attrDomainName,
      description: 'CloudFront Distribution domain name',
    });

    new cdk.CfnOutput(this, 'BucketAName', {
      value: bucketA.bucketName,
      description: 'S3 Bucket A name',
    });

    new cdk.CfnOutput(this, 'BucketBName', {
      value: bucketB.bucketName,
      description: 'S3 Bucket B name',
    });

    new cdk.CfnOutput(this, 'ABTestHeaderExample', {
      value: 'X-AB-Test: variant-a or X-AB-Test: variant-b',
      description: 'A/B test header usage examples',
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Full sample project is below:

GitHub logo tacck / cloudfront-ab-testing

Check A/B testing on CloudFront

Welcome to your CDK TypeScript project

This is a blank project for CDK development with TypeScript.

The cdk.json file tells the CDK Toolkit how to execute your app.

Useful commands

  • npm run build compile typescript to js
  • npm run watch watch for changes and compile
  • npm run test perform the jest unit tests
  • npx cdk deploy deploy this stack to your default AWS account/region
  • npx cdk diff compare deployed stack with current state
  • npx cdk synth emits the synthesized CloudFormation template

Permison Boundly

Bootstraping

cdk bootstrap --custom-permissions-boundary builder-permissions-boundary 

https://dev.classmethod.jp/articles/cdk-minimum-deploy-policy/

Check

curl -i  https://[YOUR_CLOUDFRONT_SITE]/index.html
curl -i -H 'x-ab-test: variant-a' https://[YOUR_CLOUDFRONT_SITE]/index.html

curl -i -H 'x-ab-test: variant-b' https://[YOUR_CLOUDFRONT_SITE]/index.html

9. Deployment and Testing

# Deploy CDK stack
cdk deploy

# Test A/B functionality
# Default (Variant A)
curl -i https://[YOUR_CLOUDFRONT_DOMAIN]/index.html

# Variant A (explicit specification)
curl -i -H 'X-AB-Test: variant-a' https://[YOUR_CLOUDFRONT_DOMAIN]/index.html

# Variant B
curl -i -H 'X-AB-Test: variant-b' https://[YOUR_CLOUDFRONT_DOMAIN]/index.html
Enter fullscreen mode Exit fullscreen mode

Summary

This A/B testing system combines the following technical elements:

Key Features

  • CloudFront Function: Dynamic origin selection based on request headers
  • Origin Access Control (OAC): Latest security best practices
  • Custom Cache Policy: Cache strategy considering A/B test headers
  • Infrastructure as Code: Reproducible infrastructure construction with CDK

Benefits

  1. Low Latency: CloudFront Functions execute at the edge for high speed
  2. Cost Efficiency: 1 million executions free, then $0.10 USD per million executions
  3. Scalable: Leverages CloudFront's global network
  4. Secure: Proper access control with OAC

Application Possibilities

  • Regional content distribution
  • Device-specific optimization
  • Gradual feature releases (canary deployment)
  • Query parameter-based A/B testing
  • Personalization

This system can serve as a foundation for implementing more complex A/B testing and traffic distribution logic.
Using CDK makes infrastructure changes easy to manage, enabling continuous improvement and testing.

Top comments (0)