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
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.
1# CloudFront Origin Custom Headers2Origins:3 - DomainName: my-alb-123456.us-east-1.elb.amazonaws.com4 CustomOriginConfig:5 HTTPPort: 806 HTTPSPort: 4437 OriginProtocolPolicy: https-only8 OriginCustomHeaders:9 - HeaderName: X-Custom-Verify10 HeaderValue: "aB3$kL9#mN7@pQ2&wR5" # This is your "security"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
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.
1resource "aws_security_group_rule" "alb_from_cloudfront" {2 type = "ingress"3 from_port = 4434 to_port = 4435 protocol = "tcp"6 security_group_id = aws_security_group.alb.id7 prefix_list_ids = [data.aws_ec2_managed_prefix_list.cloudfront.id]8}910data "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:
- Add the new header value to CloudFront
- Update the ALB listener rule to accept both old and new values
- Remove the old header from CloudFront
- 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
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
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:
- CloudFront creates a service-managed Elastic Network Interface (ENI) inside your specified private subnet
- This ENI acts as a bridge between CloudFront's edge network and your VPC
- Traffic flows from CloudFront edge locations through this ENI to your private ALB over a private, AWS-managed connection
- CloudFront also creates a service-managed security group named
CloudFront-VPCOrigins-Service-SGthat 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 6Fig 4: Complete request flow from user to application — traffic never touches the public internet after CloudFront
What Can Be a VPC Origin?
- Application Load Balancers (ALBs) — in private subnets
- Network Load Balancers (NLBs) — in private subnets (non-TLS listeners only)
- EC2 Instances — in private subnets
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
Fig 3: Every attack vector from the public ALB approach is completely eliminated with VPC Origins
| Security Aspect | Public ALB (Before) | VPC Origins (After) |
|---|---|---|
| Origin Exposure | Public IP, discoverable via scanning | No public IP — completely invisible |
| CloudFront Bypass | Anyone can hit ALB directly | Impossible — ALB not on the internet |
| Custom Header Dependency | Required (fragile, can leak) | Not needed at all |
| Header Rotation | Manual 4-step process, periodic | N/A — no shared secrets |
| WAF on Origin | Often needed as extra protection | Optional — defense in depth only |
| Security Group Complexity | Prefix list + header rules + listener rules | Reference CloudFront service SG |
| DDoS on Origin | Origin IP exposed to direct DDoS | DDoS only hits CloudFront edge (Shield) |
| Compliance Posture | Origin is technically public | Origin is provably private |
| Configuration Drift | High (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
- A VPC with an Internet Gateway attached (required by CloudFront even though the subnet is private)
- A private subnet with at least one available IPv4 address (IPv6-only subnets are not supported)
- Your origin resource (ALB/NLB/EC2) deployed and in Active status in the private subnet
- A security group attached to the origin resource
Why Does the VPC Need an Internet Gateway?
Step 1: Create a Private ALB
If you're starting fresh, create an internal ALB in your private subnet:
1resource "aws_lb" "private" {2 name = "app-private-alb"3 internal = true # This is the key — internal only4 load_balancer_type = "application"5 security_groups = [aws_security_group.alb.id]6 subnets = var.private_subnet_ids78 tags = {9 Environment = "production"10 ManagedBy = "terraform"11 }12}1314resource "aws_lb_listener" "https" {15 load_balancer_arn = aws_lb.private.arn16 port = 44317 protocol = "HTTPS"18 ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"19 certificate_arn = var.certificate_arn2021 default_action {22 type = "forward"23 target_group_arn = aws_lb_target_group.app.arn24 }25}Step 2: Create the VPC Origin
1resource "aws_cloudfront_vpc_origin" "alb" {2 vpc_origin_endpoint_config {3 name = "private-alb-origin"4 arn = aws_lb.private.arn5 http_port = 806 https_port = 4437 origin_protocol_policy = "https-only"89 origin_ssl_protocols {10 items = ["TLSv1.2"]11 quantity = 112 }13 }1415 tags = {16 Environment = "production"17 }18}1920# 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:
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_id56 # Only allow traffic from CloudFront VPC Origins service SG7 ingress {8 description = "HTTPS from CloudFront VPC Origin"9 from_port = 44310 to_port = 44311 protocol = "tcp"12 security_groups = [data.aws_security_group.cloudfront_vpc_origins.id]13 }1415 egress {16 from_port = 017 to_port = 018 protocol = "-1"19 cidr_blocks = ["0.0.0.0/0"]20 }21}2223# Reference the CloudFront-managed security group24data "aws_security_group" "cloudfront_vpc_origins" {25 filter {26 name = "group-name"27 values = ["CloudFront-VPCOrigins-Service-SG"]28 }2930 vpc_id = var.vpc_id31}Service-Managed SG vs Prefix List
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
1resource "aws_cloudfront_distribution" "app" {2 enabled = true3 is_ipv6_enabled = true4 comment = "App distribution with VPC Origin"5 default_root_object = "index.html"6 aliases = ["app.example.com"]78 origin {9 domain_name = aws_lb.private.dns_name10 origin_id = "private-alb"1112 vpc_origin_config {13 vpc_origin_id = aws_cloudfront_vpc_origin.alb.id14 origin_keepalive_timeout = 515 origin_read_timeout = 3016 }17 }1819 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"2425 forwarded_values {26 query_string = true27 headers = ["Host", "Origin", "Authorization"]2829 cookies {30 forward = "all"31 }32 }33 }3435 viewer_certificate {36 acm_certificate_arn = var.cloudfront_certificate_arn37 ssl_support_method = "sni-only"38 minimum_protocol_version = "TLSv1.2_2021"39 }4041 web_acl_id = aws_wafv2_web_acl.app.arn4243 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
1# Step 1: Create VPC Origin2aws 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": 112 }13 }'1415# Step 2: Check deployment status (wait for "Deployed")16aws cloudfront get-vpc-origin --id EVPC_ORIGIN_ID \17 --query 'VpcOrigin.Status'1819# Step 3: Create staging distribution via continuous deployment20aws cloudfront create-continuous-deployment-policy \21 --continuous-deployment-policy-config '{22 "StagingDistributionDnsNames": {23 "Items": ["staging.example.com"],24 "Quantity": 125 },26 "Enabled": true,27 "TrafficConfig": {28 "SingleWeightConfig": {29 "Weight": 0.130 },31 "Type": "SingleWeight"32 }33 }'3435# Step 4: After testing, promote staging to production36aws cloudfront update-distribution-with-staging-config \37 --id E_PRODUCTION_DIST_ID \38 --staging-distribution-id E_STAGING_DIST_IDMigration Consideration
Limitations to Know
VPC Origins are powerful, but there are important constraints to be aware of:
- No WebSocket support — if your application uses WebSockets, you'll need an alternative path (e.g., API Gateway WebSocket API)
- No gRPC support — gRPC traffic cannot flow through VPC Origins
- No Lambda@Edge on origin events — origin-request and origin-response Lambda@Edge triggers are not supported
- NLB restrictions — dual-stack NLBs, NLBs with TLS listeners, and NLBs without security groups are not supported
- IPv4 required — the private subnet needs at least one available IPv4 address; IPv6-only subnets are not supported
- No Gateway Load Balancers — only ALB, NLB, and EC2 instances are supported
Cross-Account Support (Added November 2025)
Cost Implications
Switching to VPC Origins can actually reduce your AWS bill:
- No extra WAF costs — you no longer need WAF on the ALB (only on CloudFront)
- No public subnet overhead — fewer NAT gateways, fewer public IPs, simpler networking
- No Elastic IP costs — internal ALBs don't use public IPs
- VPC Origins itself has no additional charge — you only pay standard CloudFront data transfer rates
- Reduced operational cost — no header rotation automation, simpler security group management
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
- Before: CloudFront → Public Internet → Public ALB (bypassable) → Private App
- After: CloudFront → AWS Private Network → Private ALB (unreachable from internet) → Private App
- Key benefit: Your origin has zero public exposure. No IP to scan, no headers to leak, no workarounds to maintain.
- Migration: Use CloudFront Continuous Deployment for zero-downtime cutover.
- Cost: VPC Origins has no additional charge. You likely save money by eliminating redundant WAF and public infrastructure.
