Back to Blog
AWSCloudFrontSecurityArchitecture

Ditch the Public ALB: How CloudFront VPC Origins Changes AWS Security Forever

For years we put public ALBs behind CloudFront and called it secure. It wasn't. AWS CloudFront VPC Origins lets you use fully private ALBs — eliminating the entire class of origin-bypass attacks.

Tejas Gupta

Tejas Gupta

March 14, 2025 · 12 min read

For years, every AWS architecture diagram I reviewed had the same pattern: CloudFront in front, an internet-facing Application Load Balancer behind it, and the application tucked away in private subnets. We all drew it. We all deployed it. And most of us believed it was secure.

It wasn't. The ALB had a public IP address. Anyone with a port scanner could find it. Anyone who found it could bypass CloudFront entirely — skipping your WAF rules, your geo-restrictions, your rate limiting, everything. The entire security posture of your edge layer was built on a foundation of hope: hope that nobody would discover your origin.

At re:Invent 2024, AWS announced CloudFront VPC Origins — a feature that changes this fundamentally. Your ALB can now be fully private. No public IP. No internet gateway route. No exposure. CloudFront connects to it through an AWS-managed private tunnel inside your VPC.

This isn't an incremental improvement. This is an architectural shift from "defense-in-depth around a public resource" to "the resource is simply not on the internet."


The Architecture We All Built (And Why It Was Flawed)

Let me paint the picture. You're a platform engineer setting up a new service on AWS. You want CloudFront in front for caching, SSL termination, and WAF protection. Behind CloudFront, you place an Application Load Balancer to distribute traffic to your ECS tasks or EC2 instances in private subnets.

Here's the catch: CloudFront needs to reach your ALB over the internet. So your ALB must be internet-facing — sitting in a public subnet with a public IP address.

Traditional Architecture — Public ALB + CloudFront

Users /InternetHTTPSAWS EDGECloudFrontCDN DistributionPUBLICVPCPUBLIC SUBNETALBPublic-facingInternet GatewayPublic IP / EIP⚠ ExposedPRIVATE SUBNETEC2 / ECS TasksApplication⚠ ALB has a public IP — anyone can bypassCloudFront and hit the ALB directly⚠ Requires custom headers / WAF rulesto restrict origin access (brittle)⚠ DDoS attack surface — public endpointneeds Shield Advanced / rate limiting

Traditional architecture — ALB sits in a public subnet with a public IP, exposing it to direct internet access

The moment you make that ALB internet-facing, you've created a backdoor to your application. Anyone can hit it directly, completely bypassing CloudFront and every security layer you've carefully configured at the edge.

The Workarounds We Invented

The industry responded with a stack of workarounds. Each one added complexity, and none of them solved the fundamental problem.

Workaround 1: Custom Header Injection

The most common approach: configure CloudFront to inject a secret custom header on every origin request, then configure the ALB to reject any request without that header.

cloudfront-origin-config.yaml
yaml
1# CloudFront Origin Custom Headers
2Origins:
3 - DomainName: my-alb-123456.us-east-1.elb.amazonaws.com
4 CustomOriginConfig:
5 HTTPPort: 80
6 HTTPSPort: 443
7 OriginProtocolPolicy: https-only
8 OriginCustomHeaders:
9 - HeaderName: X-Custom-Verify
10 HeaderValue: "aB3$kL9#mN7@pQ2&wR5" # This is your "security"
alb-listener-rule.json
json
1{
2 "Conditions": [
3 {
4 "Field": "http-header",
5 "HttpHeaderConfig": {
6 "HttpHeaderName": "X-Custom-Verify",
7 "Values": ["aB3$kL9#mN7@pQ2&wR5"]
8 }
9 }
10 ],
11 "Actions": [
12 {
13 "Type": "forward",
14 "TargetGroupArn": "arn:aws:elasticloadbalancing:..."
15 }
16 ]
17}

The Problem with Secret Headers

You're treating an HTTP header value as a security credential. This value can leak through access logs, error messages, debug endpoints, monitoring tools, or any middleware that logs request headers. If it leaks, your entire security model collapses — and you might not even know it happened.

Workaround 2: CloudFront Managed Prefix List

AWS publishes the IP ranges used by CloudFront edge locations as a managed prefix list. You can reference this in your ALB's security group to only allow traffic from CloudFront IPs.

security-group.tf
hcl
1resource "aws_security_group_rule" "alb_from_cloudfront" {
2 type = "ingress"
3 from_port = 443
4 to_port = 443
5 protocol = "tcp"
6 security_group_id = aws_security_group.alb.id
7 prefix_list_ids = [data.aws_ec2_managed_prefix_list.cloudfront.id]
8}
9
10data "aws_ec2_managed_prefix_list" "cloudfront" {
11 name = "com.amazonaws.global.cloudfront.origin-facing"
12}

This restricts access at Layer 3/4, but it allows traffic from any CloudFront distribution — not just yours. An attacker could set up their own CloudFront distribution pointing to your ALB and route traffic through it.

Workaround 3: WAF on the ALB

Some teams deploy a second WAF directly on the ALB as yet another layer of protection. This means you're paying for WAF twice and managing two rulesets — one at the edge and one at the origin.

Workaround 4: Regular Header Rotation

Because the custom header is effectively a shared secret, security best practices demand regular rotation. This is a careful 4-step manual process:

  1. Add the new header value to CloudFront
  2. Update the ALB listener rule to accept both old and new values
  3. Remove the old header from CloudFront
  4. Update the ALB listener rule to only accept the new value

Get the order wrong, and you either break production or leave the old secret active.

The Real Issue

All these workarounds are defense-in-depth around a fundamentally public resource. You're layering security controls on top of an architecture that should never have had a public endpoint in the first place. The ALB's public IP is the root cause — and no amount of headers, prefix lists, or WAF rules can change that.

Enter CloudFront VPC Origins

At re:Invent 2024, AWS introduced CloudFront VPC Origins. The concept is beautifully simple: CloudFront can now connect to origins inside your VPC's private subnets without those origins needing any public internet exposure.

New Architecture — Private ALB + CloudFront VPC Origins

Users /InternetHTTPSAWS EDGECloudFrontCDN DistributionShield + WAFVPC ORIGINVPCPRIVATE SUBNET (No Public IPs!)Private ALBInternal onlyNo IGW neededNo Public IPEC2 / ECS TasksApplicationRDS / ElastiCacheALB is completely private — zero public exposureAWS-managed secure tunnel, no custom headers neededReduced attack surface — DDoS only hits edge

New architecture — ALB is fully private with CloudFront connecting via VPC Origins (AWS-managed secure tunnel)

How It Works Under the Hood

When you create a VPC Origin, AWS does something elegant:

  1. CloudFront creates a service-managed Elastic Network Interface (ENI) inside your specified private subnet
  2. This ENI acts as a bridge between CloudFront's edge network and your VPC
  3. Traffic flows from CloudFront edge locations through this ENI to your private ALB over a private, AWS-managed connection
  4. CloudFront also creates a service-managed security group named CloudFront-VPCOrigins-Service-SG that you can reference in your ALB's security group

The traffic never traverses the public internet. It stays entirely within AWS's private network backbone.

Request Flow — End to End

Step 1 of 6

Fig 4: Complete request flow from user to application — traffic never touches the public internet after CloudFront

What Can Be a VPC Origin?


Security: Before vs After

This is where the "10x security improvement" claim becomes clear. Let me break down every attack vector and show how VPC Origins eliminates each one.

Attack Surface Comparison

Threat Score: 6/60/6
Threat VectorBeforeAfter
Origin Exposure
HIGH
ELIMINATED
CloudFront Bypass
HIGH
ELIMINATED
Custom Header Dependency
HIGH
ELIMINATED
Header Rotation Required
MEDIUM
ELIMINATED
DDoS at Origin
HIGH
ELIMINATED
Configuration Drift
MEDIUM
ELIMINATED
All 6 threat vectors eliminated with VPC Origins

Fig 3: Every attack vector from the public ALB approach is completely eliminated with VPC Origins

Security AspectPublic ALB (Before)VPC Origins (After)
Origin ExposurePublic IP, discoverable via scanningNo public IP — completely invisible
CloudFront BypassAnyone can hit ALB directlyImpossible — ALB not on the internet
Custom Header DependencyRequired (fragile, can leak)Not needed at all
Header RotationManual 4-step process, periodicN/A — no shared secrets
WAF on OriginOften needed as extra protectionOptional — defense in depth only
Security Group ComplexityPrefix list + header rules + listener rulesReference CloudFront service SG
DDoS on OriginOrigin IP exposed to direct DDoSDDoS only hits CloudFront edge (Shield)
Compliance PostureOrigin is technically publicOrigin is provably private
Configuration DriftHigh (many manual pieces)Low (AWS-managed connection)

The shift isn't from "somewhat secure" to "more secure." It's from "we hope nobody finds the ALB" to "the ALB literally cannot be found." That's not a 10% improvement — it's an architectural elimination of the entire attack class.


Implementation Guide

Let me walk you through setting up CloudFront VPC Origins from scratch, and then cover migrating an existing public ALB setup.

Prerequisites

Why Does the VPC Need an Internet Gateway?

This is a common question. The IGW is required at the VPC level for CloudFront to establish the private connection, but your private subnet does NOT need a route to the IGW. The subnet remains genuinely private — the IGW is an infrastructure prerequisite, not a traffic path.

Step 1: Create a Private ALB

If you're starting fresh, create an internal ALB in your private subnet:

alb.tf
hcl
1resource "aws_lb" "private" {
2 name = "app-private-alb"
3 internal = true # This is the key — internal only
4 load_balancer_type = "application"
5 security_groups = [aws_security_group.alb.id]
6 subnets = var.private_subnet_ids
7
8 tags = {
9 Environment = "production"
10 ManagedBy = "terraform"
11 }
12}
13
14resource "aws_lb_listener" "https" {
15 load_balancer_arn = aws_lb.private.arn
16 port = 443
17 protocol = "HTTPS"
18 ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
19 certificate_arn = var.certificate_arn
20
21 default_action {
22 type = "forward"
23 target_group_arn = aws_lb_target_group.app.arn
24 }
25}

Step 2: Create the VPC Origin

vpc-origin.tf
hcl
1resource "aws_cloudfront_vpc_origin" "alb" {
2 vpc_origin_endpoint_config {
3 name = "private-alb-origin"
4 arn = aws_lb.private.arn
5 http_port = 80
6 https_port = 443
7 origin_protocol_policy = "https-only"
8
9 origin_ssl_protocols {
10 items = ["TLSv1.2"]
11 quantity = 1
12 }
13 }
14
15 tags = {
16 Environment = "production"
17 }
18}
19
20# Wait for VPC origin to be deployed (can take up to 15 minutes)
21# The status will change from "Deploying" to "Deployed"

Step 3: Configure Security Groups

After the VPC Origin is created, AWS provisions a service-managed security group. Use it to lock down your ALB:

security-groups.tf
hcl
1resource "aws_security_group" "alb" {
2 name = "private-alb-sg"
3 description = "Security group for private ALB - CloudFront VPC Origin only"
4 vpc_id = var.vpc_id
5
6 # Only allow traffic from CloudFront VPC Origins service SG
7 ingress {
8 description = "HTTPS from CloudFront VPC Origin"
9 from_port = 443
10 to_port = 443
11 protocol = "tcp"
12 security_groups = [data.aws_security_group.cloudfront_vpc_origins.id]
13 }
14
15 egress {
16 from_port = 0
17 to_port = 0
18 protocol = "-1"
19 cidr_blocks = ["0.0.0.0/0"]
20 }
21}
22
23# Reference the CloudFront-managed security group
24data "aws_security_group" "cloudfront_vpc_origins" {
25 filter {
26 name = "group-name"
27 values = ["CloudFront-VPCOrigins-Service-SG"]
28 }
29
30 vpc_id = var.vpc_id
31}

Service-Managed SG vs Prefix List

You have two options for security groups. The service-managed SG (CloudFront-VPCOrigins-Service-SG) is the recommended approach because it restricts traffic to only your CloudFront distributions. The managed prefix list allows traffic from any CloudFront distribution, which is less restrictive.

Step 4: Create the CloudFront Distribution

cloudfront.tf
hcl
1resource "aws_cloudfront_distribution" "app" {
2 enabled = true
3 is_ipv6_enabled = true
4 comment = "App distribution with VPC Origin"
5 default_root_object = "index.html"
6 aliases = ["app.example.com"]
7
8 origin {
9 domain_name = aws_lb.private.dns_name
10 origin_id = "private-alb"
11
12 vpc_origin_config {
13 vpc_origin_id = aws_cloudfront_vpc_origin.alb.id
14 origin_keepalive_timeout = 5
15 origin_read_timeout = 30
16 }
17 }
18
19 default_cache_behavior {
20 allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
21 cached_methods = ["GET", "HEAD"]
22 target_origin_id = "private-alb"
23 viewer_protocol_policy = "redirect-to-https"
24
25 forwarded_values {
26 query_string = true
27 headers = ["Host", "Origin", "Authorization"]
28
29 cookies {
30 forward = "all"
31 }
32 }
33 }
34
35 viewer_certificate {
36 acm_certificate_arn = var.cloudfront_certificate_arn
37 ssl_support_method = "sni-only"
38 minimum_protocol_version = "TLSv1.2_2021"
39 }
40
41 web_acl_id = aws_wafv2_web_acl.app.arn
42
43 restrictions {
44 geo_restriction {
45 restriction_type = "none"
46 }
47 }
48}

Migrating an Existing Public ALB

If you already have a public ALB + CloudFront setup, AWS provides a safe migration path using Continuous Deployment (staging distributions). Here's the recommended approach:

0/7 steps complete

migration-commands.sh
bash
1# Step 1: Create VPC Origin
2aws cloudfront create-vpc-origin \
3 --vpc-origin-endpoint-config '{
4 "Name": "private-alb-origin",
5 "Arn": "arn:aws:elasticloadbalancing:us-east-1:123456789:loadbalancer/app/my-private-alb/abc123",
6 "HTTPPort": 80,
7 "HTTPSPort": 443,
8 "OriginProtocolPolicy": "https-only",
9 "OriginSslProtocols": {
10 "Items": ["TLSv1.2"],
11 "Quantity": 1
12 }
13 }'
14
15# Step 2: Check deployment status (wait for "Deployed")
16aws cloudfront get-vpc-origin --id EVPC_ORIGIN_ID \
17 --query 'VpcOrigin.Status'
18
19# Step 3: Create staging distribution via continuous deployment
20aws cloudfront create-continuous-deployment-policy \
21 --continuous-deployment-policy-config '{
22 "StagingDistributionDnsNames": {
23 "Items": ["staging.example.com"],
24 "Quantity": 1
25 },
26 "Enabled": true,
27 "TrafficConfig": {
28 "SingleWeightConfig": {
29 "Weight": 0.1
30 },
31 "Type": "SingleWeight"
32 }
33 }'
34
35# Step 4: After testing, promote staging to production
36aws cloudfront update-distribution-with-staging-config \
37 --id E_PRODUCTION_DIST_ID \
38 --staging-distribution-id E_STAGING_DIST_ID

Migration Consideration

VPC Origin deployment can take up to 15 minutes. To update a VPC Origin, you must first disassociate it from all distributions, edit it, wait for redeployment, then re-associate. Plan your maintenance windows accordingly.

Limitations to Know

VPC Origins are powerful, but there are important constraints to be aware of:

Cross-Account Support (Added November 2025)

As of November 2025, VPC Origins support cross-account sharing via AWS Resource Access Manager (RAM). This is particularly useful for centralized networking architectures where CloudFront distributions live in one account while application workloads run in another.

Cost Implications

Switching to VPC Origins can actually reduce your AWS bill:


The Bigger Picture

CloudFront VPC Origins represents a broader shift in AWS's approach to edge-to-origin connectivity. For years, the boundary between CloudFront and your VPC required a public endpoint. Now, AWS is erasing that boundary.

If you're a platform engineer, this should be high on your migration backlog. The security gains are not marginal — they're architectural. You're not adding another lock to a door that's on a public street. You're moving the door inside a building that has no street entrance.

The custom header approach served us well for years. It was the best we had. But it was always a workaround for a missing feature. That feature has now arrived.

The best security is when the resource you're protecting doesn't exist on the attack surface at all. VPC Origins makes that possible for your ALB.


TL;DR

Enjoyed this article?

If this deep-dive saved you hours of research or helped you make better architecture decisions, consider supporting my work. Every bit helps me keep writing quality technical content.

No pressure — sharing the article helps just as much!

Share