Back to Blog

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.

Viprasol Tech Team
January 18, 2027
13 min read

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

FeatureLambda@EdgeCloudFront Functions
RuntimeNode.js 18/20JavaScript (ES5.1 subset)
Execution timeUp to 5s (viewer) / 30s (origin)1ms max
MemoryUp to 128MB2MB
Access to request bodyYes (origin request/response)No
Network callsYesNo
Use caseAuth, API calls, complex logicURL 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 CaseInvocations/monthLambda@Edge CostCloudFront Functions Cost
Auth (all requests)50M$30 + duration$5
Geo routing (cache misses only)5M$3 + durationN/A (needs network)
A/B testing50M$30 + duration$5
Security headers50M$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


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need DevOps & Cloud Expertise?

Scale your infrastructure with confidence. AWS, GCP, Azure certified team.

Free consultation • No commitment • Response within 24 hours

Viprasol · Big Data & Analytics

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.