AWS CloudFront Lambda@Edge in 2026: Auth, Geo-Routing, and A/B Testing at the CDN Edge
Run logic at the CDN edge with Lambda@Edge: JWT authentication, geo-based routing, A/B testing with sticky assignments, custom headers, and Terraform deployment patterns.
AWS CloudFront Lambda@Edge in 2026: Auth, Geo-Routing, and A/B Testing at the CDN Edge
Lambda@Edge runs your code in AWS edge locations—the same PoPs that serve your CDN cache—without a round-trip to your origin. This means authentication, geo-routing, and A/B testing decisions happen within milliseconds of the user's request, regardless of where your origin server lives.
This post covers the four Lambda@Edge trigger points, JWT authentication that rejects requests before they reach your origin, geo-based routing with CloudFront's built-in country header, sticky A/B test assignment, and Terraform deployment. We also cover CloudFront Functions, the lighter-weight alternative for simpler use cases.
Lambda@Edge vs CloudFront Functions
| Feature | Lambda@Edge | CloudFront Functions |
|---|---|---|
| Runtime | Node.js 18/20 | JavaScript (ES5.1 subset) |
| Execution time | Up to 5s (viewer) / 30s (origin) | 1ms max |
| Memory | Up to 128MB | 2MB |
| Access to request body | Yes (origin request/response) | No |
| Network calls | Yes | No |
| Use case | Auth, API calls, complex logic | URL rewrites, header manipulation |
| Pricing | $0.60/M invocations + duration | $0.10/M invocations |
Rule of thumb: Use CloudFront Functions for pure URL/header manipulation. Use Lambda@Edge when you need network calls (Redis, JWT validation with a key fetch, geo database lookup).
The Four Trigger Points
Browser → [Viewer Request] → CloudFront Cache → [Origin Request] → Your Origin
↓
Browser ← [Viewer Response] ← CloudFront Cache ← [Origin Response] ← Your Origin
- Viewer Request: Runs before cache check. Can modify the request or return a response directly (auth, redirects, URL normalization).
- Origin Request: Runs on cache miss, before forwarding to origin. Can modify headers sent to origin or select a different origin.
- Origin Response: Runs when origin returns a response. Can modify response headers or body.
- Viewer Response: Runs before sending response to browser. Can add security headers, modify cookies.
☁️ Is Your Cloud Costing Too Much?
Most teams overspend 30–40% on cloud — wrong instance types, no reserved pricing, bloated storage. We audit, right-size, and automate your infrastructure.
- AWS, GCP, Azure certified engineers
- Infrastructure as Code (Terraform, CDK)
- Docker, Kubernetes, GitHub Actions CI/CD
- Typical audit recovers $500–$3,000/month in savings
Pattern 1: JWT Authentication at the Edge
// edge/auth/viewer-request.ts
// Validates JWT before the request reaches CloudFront cache or origin
import { CloudFrontRequestEvent, CloudFrontRequestResult } from "aws-lambda";
// JWT validation without network calls — public key embedded at deploy time
// Rotate by redeploying the edge function
const PUBLIC_KEY_PEM = process.env.JWT_PUBLIC_KEY!;
interface JWTPayload {
sub: string;
teamId: string;
role: string;
exp: number;
}
function base64UrlDecode(str: string): Uint8Array {
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
const padLength = (4 - (padded.length % 4)) % 4;
const base64 = padded + "=".repeat(padLength);
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
}
async function verifyJWT(token: string): Promise<JWTPayload | null> {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
// Import public key
const keyData = base64UrlDecode(
PUBLIC_KEY_PEM.replace(/-----BEGIN PUBLIC KEY-----/, "")
.replace(/-----END PUBLIC KEY-----/, "")
.replace(/\s/g, "")
);
const publicKey = await crypto.subtle.importKey(
"spki",
keyData,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["verify"]
);
const dataToVerify = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
publicKey,
signature,
dataToVerify
);
if (!valid) return null;
const payload: JWTPayload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(payloadB64))
);
if (payload.exp < Math.floor(Date.now() / 1000)) return null; // Expired
return payload;
} catch {
return null;
}
}
// Public paths that don't require auth
const PUBLIC_PATHS = new Set(["/", "/login", "/signup", "/api/webhooks"]);
function isPublicPath(uri: string): boolean {
if (PUBLIC_PATHS.has(uri)) return true;
if (uri.startsWith("/public/")) return true;
if (uri.startsWith("/_next/static/")) return true;
return false;
}
export const handler = async (
event: CloudFrontRequestEvent
): Promise<CloudFrontRequestResult> => {
const request = event.Records[0].cf.request;
const { uri, headers, querystring } = request;
// Skip auth for public paths
if (isPublicPath(uri)) return request;
// Extract token from Authorization header or cookie
const authHeader = headers["authorization"]?.[0]?.value;
const cookieHeader = headers["cookie"]?.[0]?.value ?? "";
const cookieToken = cookieHeader
.split(";")
.find((c) => c.trim().startsWith("auth_token="))
?.split("=")[1]
?.trim();
const token = authHeader?.replace("Bearer ", "") ?? cookieToken;
if (!token) {
// Redirect to login
return {
status: "302",
statusDescription: "Found",
headers: {
location: [{ key: "Location", value: `/login?next=${encodeURIComponent(uri)}` }],
"cache-control": [{ key: "Cache-Control", value: "no-cache" }],
},
};
}
const payload = await verifyJWT(token);
if (!payload) {
return {
status: "302",
statusDescription: "Found",
headers: {
location: [{ key: "Location", value: "/login?error=session_expired" }],
"set-cookie": [{ key: "Set-Cookie", value: "auth_token=; Max-Age=0; Path=/; HttpOnly; Secure" }],
"cache-control": [{ key: "Cache-Control", value: "no-cache" }],
},
};
}
// Inject user context into request headers for origin
request.headers["x-user-id"] = [{ key: "x-user-id", value: payload.sub }];
request.headers["x-team-id"] = [{ key: "x-team-id", value: payload.teamId }];
request.headers["x-user-role"] = [{ key: "x-user-role", value: payload.role }];
return request;
};
Pattern 2: Geo-Based Routing
CloudFront automatically adds CloudFront-Viewer-Country header. Route to regional origins:
// edge/geo-routing/origin-request.ts
import { CloudFrontRequestEvent, CloudFrontRequestResult } from "aws-lambda";
// Map country codes to regional API origins
const GEO_ORIGIN_MAP: Record<string, string> = {
EU: "api-eu.yoursaas.com", // Germany, France, Netherlands, etc.
UK: "api-eu.yoursaas.com", // Post-Brexit, GDPR still applies
AU: "api-apac.yoursaas.com",
JP: "api-apac.yoursaas.com",
SG: "api-apac.yoursaas.com",
IN: "api-apac.yoursaas.com",
};
// Countries in EU (for GDPR routing)
const EU_COUNTRIES = new Set([
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI",
"FR", "GR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT",
"NL", "PL", "PT", "RO", "SE", "SI", "SK",
]);
const DEFAULT_ORIGIN = "api-us.yoursaas.com";
export const handler = async (
event: CloudFrontRequestEvent
): Promise<CloudFrontRequestResult> => {
const request = event.Records[0].cf.request;
// CloudFront provides viewer country
const countryCode = request.headers["cloudfront-viewer-country"]?.[0]?.value ?? "US";
// Determine regional origin
let originDomain = DEFAULT_ORIGIN;
if (EU_COUNTRIES.has(countryCode)) {
originDomain = GEO_ORIGIN_MAP.EU;
} else if (countryCode in GEO_ORIGIN_MAP) {
originDomain = GEO_ORIGIN_MAP[countryCode];
}
// Override the origin
request.origin = {
custom: {
domainName: originDomain,
port: 443,
protocol: "https",
path: "",
sslProtocols: ["TLSv1.2"],
readTimeout: 30,
keepaliveTimeout: 60,
customHeaders: {},
},
};
// Propagate country to origin for logging
request.headers["x-viewer-country"] = [{ key: "x-viewer-country", value: countryCode }];
request.headers["x-data-region"] = [{ key: "x-data-region", value: originDomain.split(".")[0] }];
return request;
};
⚙️ DevOps Done Right — Zero Downtime, Full Automation
Ship faster without breaking things. We build CI/CD pipelines, monitoring stacks, and auto-scaling infrastructure that your team can actually maintain.
- Staging + production environments with feature flags
- Automated security scanning in the pipeline
- Uptime monitoring + alerting + runbook automation
- On-call support handover docs included
Pattern 3: A/B Testing with Sticky Assignment
// edge/ab-test/viewer-request.ts
// Assigns users to variants and makes assignment sticky via cookie
import { CloudFrontRequestEvent, CloudFrontRequestResult } from "aws-lambda";
interface Experiment {
id: string;
variants: Array<{ id: string; weight: number; originPath?: string }>;
paths: RegExp[]; // Which paths to run this experiment on
}
const EXPERIMENTS: Experiment[] = [
{
id: "checkout-flow-v2",
paths: [/^\/checkout/],
variants: [
{ id: "control", weight: 0.5 },
{ id: "variant-a", weight: 0.5, originPath: "/checkout-v2" },
],
},
{
id: "pricing-page",
paths: [/^\/pricing/],
variants: [
{ id: "control", weight: 0.33 },
{ id: "variant-a", weight: 0.33 },
{ id: "variant-b", weight: 0.34 },
],
},
];
function assignVariant(experiment: Experiment, seed: number): string {
const random = seed % 1000 / 1000; // 0–0.999
let cumulative = 0;
for (const variant of experiment.variants) {
cumulative += variant.weight;
if (random < cumulative) return variant.id;
}
return experiment.variants[experiment.variants.length - 1].id;
}
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0; // Convert to 32-bit int
}
return Math.abs(hash);
}
export const handler = async (
event: CloudFrontRequestEvent
): Promise<CloudFrontRequestResult> => {
const request = event.Records[0].cf.request;
const { uri, headers } = request;
const cookieHeader = headers["cookie"]?.[0]?.value ?? "";
const cookies = Object.fromEntries(
cookieHeader.split(";").map((c) => {
const [k, ...v] = c.trim().split("=");
return [k, v.join("=")];
})
);
// User identifier for stable assignment (use session ID or anonymous ID)
const userId = cookies["user_id"] ?? cookies["anon_id"] ?? `anon-${Math.random()}`;
const newCookies: string[] = [];
for (const experiment of EXPERIMENTS) {
// Check if this path is in scope for this experiment
const inScope = experiment.paths.some((p) => p.test(uri));
if (!inScope) continue;
const cookieKey = `ab_${experiment.id}`;
// Use existing assignment if present (sticky)
let variantId = cookies[cookieKey];
if (!variantId) {
// Assign based on userId + experiment ID (deterministic)
const seed = hashString(`${userId}:${experiment.id}`);
variantId = assignVariant(experiment, seed);
// Set cookie for 30 days
newCookies.push(
`${cookieKey}=${variantId}; Max-Age=${30 * 86400}; Path=/; HttpOnly; Secure; SameSite=Lax`
);
}
// Inject variant into request headers for origin logging
request.headers[`x-ab-${experiment.id}`] = [
{ key: `x-ab-${experiment.id}`, value: variantId },
];
// Redirect to variant path if configured
const variant = experiment.variants.find((v) => v.id === variantId);
if (variant?.originPath && uri !== variant.originPath) {
request.uri = variant.originPath + uri.replace(/^\/[^/]+/, "");
}
}
// Set assignment cookies in the response — handled at viewer-response trigger
// For now, pass assignments in a header for the origin to echo back as Set-Cookie
if (newCookies.length > 0) {
request.headers["x-set-cookies"] = [
{ key: "x-set-cookies", value: newCookies.join(" || ") },
];
}
return request;
};
Pattern 4: Security Headers (Viewer Response)
// edge/security-headers/viewer-response.ts
import { CloudFrontResponseEvent, CloudFrontResponseResult } from "aws-lambda";
export const handler = async (
event: CloudFrontResponseEvent
): Promise<CloudFrontResponseResult> => {
const response = event.Records[0].cf.response;
const securityHeaders: Record<string, string> = {
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
"Content-Security-Policy": [
"default-src 'self'",
"script-src 'self' 'nonce-{{nonce}}' https://js.stripe.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https://images.yoursaas.com",
"connect-src 'self' https://api.stripe.com",
"frame-src https://js.stripe.com",
"object-src 'none'",
"base-uri 'self'",
].join("; "),
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=(self)",
};
for (const [header, value] of Object.entries(securityHeaders)) {
response.headers[header.toLowerCase()] = [{ key: header, value }];
}
return response;
};
Terraform Deployment
# terraform/cloudfront-lambda-edge.tf
# Lambda@Edge must be deployed to us-east-1
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
resource "aws_lambda_function" "auth_edge" {
provider = aws.us_east_1
function_name = "${var.name}-auth-edge"
runtime = "nodejs20.x"
handler = "dist/auth/viewer-request.handler"
filename = data.archive_file.edge_bundle.output_path
source_code_hash = data.archive_file.edge_bundle.output_base64sha256
role = aws_iam_role.edge_lambda.arn
# Lambda@Edge: publish a version (required for CloudFront association)
publish = true
# Edge functions have stricter limits
memory_size = 128
timeout = 5 # Viewer trigger max: 5 seconds
environment {
variables = {
JWT_PUBLIC_KEY = var.jwt_public_key
}
}
}
resource "aws_cloudfront_distribution" "main" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
aliases = [var.domain_name]
origin {
domain_name = var.origin_domain
origin_id = "default-origin"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
default_cache_behavior {
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "default-origin"
viewer_protocol_policy = "redirect-to-https"
compress = true
forwarded_values {
query_string = true
headers = ["Authorization", "CloudFront-Viewer-Country", "Origin"]
cookies { forward = "all" }
}
# Associate Lambda@Edge functions
lambda_function_association {
event_type = "viewer-request"
lambda_arn = "${aws_lambda_function.auth_edge.arn}:${aws_lambda_function.auth_edge.version}"
include_body = false
}
lambda_function_association {
event_type = "viewer-response"
lambda_arn = "${aws_lambda_function.security_headers_edge.arn}:${aws_lambda_function.security_headers_edge.version}"
include_body = false
}
}
# Cache behavior for static assets — no Lambda needed
ordered_cache_behavior {
path_pattern = "/_next/static/*"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "default-origin"
viewer_protocol_policy = "redirect-to-https"
compress = true
forwarded_values {
query_string = false
cookies { forward = "none" }
}
min_ttl = 31536000 # 1 year
default_ttl = 31536000
max_ttl = 31536000
}
restrictions {
geo_restriction { restriction_type = "none" }
}
viewer_certificate {
acm_certificate_arn = var.acm_certificate_arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
}
Cost Estimates
| Use Case | Invocations/month | Lambda@Edge Cost | CloudFront Functions Cost |
|---|---|---|---|
| Auth (all requests) | 50M | $30 + duration | $5 |
| Geo routing (cache misses only) | 5M | $3 + duration | N/A (needs network) |
| A/B testing | 50M | $30 + duration | $5 |
| Security headers | 50M | $30 + duration | $5 |
| Security headers (CF Functions) | 50M | — | $5 |
Security headers with no business logic → CloudFront Functions ($5/month vs $30+).
Auth with JWT verification → Lambda@Edge (needs crypto.subtle).
See Also
- AWS CloudWatch Observability — Monitoring Lambda@Edge invocations
- AWS WAF Security — WAF rules attached to the same CloudFront distribution
- Next.js Middleware — App-layer alternative to edge auth
- Terraform State Management — Managing multi-region Terraform state
Working With Viprasol
We design and deploy CloudFront + Lambda@Edge architectures for SaaS products—from edge authentication through multi-region geo-routing with data residency compliance. Our cloud team has shipped edge compute systems serving hundreds of millions of monthly requests.
What we deliver:
- JWT authentication at the edge (zero origin load for auth failures)
- Geo-based origin routing for GDPR/data residency requirements
- A/B testing infrastructure with sticky variant assignment
- Security headers enforcement via CloudFront Functions
- Terraform modules for repeatable edge deployments
See our cloud infrastructure services or contact us to add edge logic to your CloudFront distribution.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need DevOps & Cloud Expertise?
Scale your infrastructure with confidence. AWS, GCP, Azure certified team.
Free consultation • No commitment • Response within 24 hours
Making sense of your data at scale?
Viprasol builds end-to-end big data analytics solutions — ETL pipelines, data warehouses on Snowflake or BigQuery, and self-service BI dashboards. One reliable source of truth for your entire organisation.