The Problem
Every team that puts CloudFront in front of an internet-facing ALB faces the same awkward question: how does the origin know a request actually came through CloudFront? The origin has a public DNS name; anyone who discovers it can bypass the CDN — and with it your WAF rules, rate limiting, geo restrictions, and caching layer.
The industry-standard answers have always been compromises. The shared-secret custom header (X-Origin-Verify: <random-string>) is a bearer token: anyone who ever sees the value — in a log line, a config dump, a packet capture inside the origin VPC, an ex-employee’s notes — can impersonate CloudFront forever, because rotation is manual, two-sided, and therefore rare. The managed prefix list (com.amazonaws.global.cloudfront.origin-facing) restricts by source IP, which proves the request came from someCloudFront distribution — including any other AWS customer’s distribution pointed at your origin. Neither approach gives the origin a cryptographic statement of identity.
In November 2025 AWS closed this gap: CloudFront can now present an X.509 client certificate to origins during the TLS handshake (origin mTLS). The origin — an ALB with a mutual-authentication trust store, an API Gateway, or any custom origin that requests client certificates — verifies that the certificate chains to a CA youcontrol. Trust moves from “knows a secret” / “comes from the right IP block” to “holds a private key whose certificate my CA signed”. That is a different security class: the private key never leaves ACM, nothing secret transits in headers, and revocation/rotation is a PKI operation instead of a coordinated config dance.
This post analyses the feature from an architect’s seat — the trust model, where it fits relative to OAC and prefix lists, what it refuses to do, and how to adopt it while the Terraform AWS provider has not yet caught up — using a deliberately minimal lab: CloudFront → ALB (verify mode) → nginx on EC2, with a self-signed CA issuing both leaf certificates.
Architecture & Data Flow
The lab is a one-way gate where the CDN itself is the only key-holder. Everything exists to prove a single boundary: the CloudFront→ALB hop is mutually authenticated, so the origin is unreachable except through this exact distribution. The VPC, EC2, and nginx are the minimum scaffolding needed to observe that handshake.

Phase 1 — Offline PKI bootstrap (one-shot, before the first plan)
A script mints a self-signed root CA (P-256, 7-day validity) and signs two EKU-scoped leaves from the same root: cf-client with Extended Key Usage clientAuth (CloudFront’s identity) and alb-server with EKU serverAuth (the ALB listener certificate). It imports both to ACM via the CLI — the client cert to us-east-1, mandatory regardless of origin region because CloudFront’s control plane is homed there — then shreds every private key, including the CA’s own, so no further certificates can ever be minted against this trust store. Only the public CA certificate (no keys) is uploaded to S3 as the PEM trust bundle.
Phase 2 — The verifying listener
The ALB trust store is created from the S3 object — pinned to an exact S3 object version, so overwriting the bucket can’t silently change what the listener trusts. The HTTPS listener runs in verify mode: it demands and validates a client certificate on every TLS handshake, before any HTTP is spoken.
# modules/cloudfront_mtls_origin/main.tf
resource "aws_lb_listener" "https_mtls" {
load_balancer_arn = aws_lb.this.arn
port = 443
protocol = "HTTPS"
ssl_policy = var.ssl_policy
certificate_arn = var.server_certificate_arn
# The point of the lab: the listener REJECTS any TLS handshake that does
# not present a cert chaining to the trust-store CA — only CloudFront gets in.
mutual_authentication {
mode = "verify"
trust_store_arn = aws_lb_trust_store.this.arn
}
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.origin.arn
}
}Phase 3 — Per-origin attachment at the edge
The distribution’s origin gains an OriginMtlsConfig referencing the ACM certificate ARN. The setting is per origin, so one distribution can present different identities to different backends — a hardened cert for the API origin, none for a public static origin. CloudFront’s certificate picker only lists ACM certs whose EKU includes TLS Client Authentication.
# templates/distribution.yaml.tpl — the CloudFormation escape hatch.
# hashicorp/aws does not expose OriginMtlsConfig yet, so ONLY the
# distribution lives in an aws_cloudformation_stack.
Origins:
- Id: alb-origin
DomainName: "${alb_dns_name}"
CustomOriginConfig:
OriginProtocolPolicy: https-only
# CloudFront presents this ACM (us-east-1) client cert to the ALB,
# which verifies it against its trust store.
OriginMtlsConfig:
CertificateArn: "${client_certificate_arn}"Phase 4 — The runtime handshake
- A viewer hits CloudFront over normal TLS; on a cache miss CloudFront opens TLS to the ALB.
- The ALB sends a
CertificateRequest; CloudFront answers with thecf-clientcertificate; the ALB validates the chain against its trust store (and CloudFront validates the ALB’s server certificate as usual). - Only then does HTTP flow — the ALB forwards over HTTP :80 to nginx, which serves a deterministic marker page. A direct
curl https://<alb-dns-name>without a valid client cert fails at the handshake; the request never reaches nginx.
The permission boundary worth noticing: the ALB enforces identity at the TLS layer, before any layer-7 logic runs — unlike the shared-secret header, which is checked by listener rules or the application after the connection is already established. Containment around the scaffolding is a two-way security-group pair (ALB egress only to the origin SG on :80; origin ingress only from the ALB SG), no SSH anywhere (SSM Session Manager only), IMDSv2 required, and encrypted EBS.
Validation status
Design Decisions
mTLS over the shared-secret header — identity, not a bearer token
The header is a static bearer credential whose secrecy degrades over time and whose rotation requires synchronized changes on both CloudFront and the ALB rule. The client certificate is asymmetric: the origin only ever stores the public CA cert, so compromising the origin reveals nothing that lets an attacker impersonate CloudFront. The trade-off: configuration errors fail closed (502s at the origin) rather than open — operationally louder, which is exactly what you want from an authentication control.
ALB verify mode over passthrough
Passthrough forwards the client cert to targets in HTTP headers and leaves validation to the application — re-creating the “app must check something” pattern this feature exists to retire. Verify makes the load balancer the policy enforcement point: invalid chains never produce an HTTP request. The cost is coarser granularity (any unexpired cert under the trusted CA passes), acceptable when the CA is dedicated to this single purpose.
Self-signed CA over AWS Private CA
ACM Private CA is the production answer (managed lifecycle, renewal notifications, audit trail) but costs ~$400/month — absurd for a disposable lab. A self-signed root issuing exactly two EKU-disjoint leaves costs $0 and teaches the trust-store mechanics explicitly. Decision rule: lab → self-signed; anything with uptime expectations → Private CA or a third-party CA with a written renewal runbook, because imported certs do not auto-renew.
CFN-stack quarantine for the provider gap
The Terraform AWS provider has no OriginMtlsConfig argument yet, so the lab wraps only the CloudFront distribution in a single aws_cloudformation_stack; everything else stays native HCL. The alternatives were weighed: the AWSCC provider would move the entire distribution onto a less mature provider, and a post-apply CLI shim drifts silently because the old schema cannot even see the field. The quarantine keeps the unsupported attribute behind the smallest possible blast radius, with a written native-swap path (native resource → terraform import → delete the stack with retain_resources) so the workaround cannot fossilize.
Certificates minted outside Terraform — keys never touch state
Using tls_* or aws_acm_certificate resources would serialize private keys into Terraform state. Instead an external script runs OpenSSL plus aws acm import-certificate; Terraform consumes only the two ACM ARNs as gitignored input variables. The script also validates EKU at build time — asserting each leaf carries exactly its intended EKU and rejecting over-scoped certificates (a leaf valid for both clientAuth and serverAuth fails the mint) — turning a common console surprise into a deterministic check.
Reuse vs author — module boundaries follow the feature gap
The lab vendored an existing network module (it already supports public-only/no-NAT) but authored a new cloudfront_mtls_origin module, because the library ALB and CloudFront modules predate the feature and model neither trust stores nor origin mTLS. The rule that emerged: reuse when the library module models your construct; author when you would be patching its core abstraction. Don’t fork a golden-source module to bolt on a brand-new AWS feature.
Provider-constraint intersection — the silent override
The environment claimed a >= 5.80.0 provider floor, but the vendored network module pinned >= 6.0.0, < 7.0.0 — and Terraform intersects constraints across all modules, so the effective floor was silently 6.0.0. Caught and fixed before apply: the environment and authored module were aligned to the bounded form while leaving the vendored module untouched. Your own versions.tf documents an intention; the intersection across every module in the graph is the contract.
Keep or drop the old header during migration?
Run both: enable origin mTLS and keep the X-Origin-Verify rule until handshake metrics look clean, then remove the header rule and its secret. Defense-in-depth layering (mTLS + prefix-list SG + WAF) remains valid — the secret-bearing layer is the one being retired. The lab itself went pure mTLS from day one to stay minimal; the dual-running pattern is documented rather than built.
Extended Knowledge
EKU semantics — why clientAuth is the gate
Extended Key Usage (RFC 5280) constrains what a certificate may be used for: serverAuth authorizes a cert to identify a TLS server, clientAuth a TLS client. CloudFront enforces this at the control plane: a cert minted without the extension simply never appears in the certificate picker — a common first-run failure with default OpenSSL settings. The deeper point: EKU is how one CA can safely issue multiple identities. Without disjoint EKUs, a leaked server cert could be replayed as a client credential against your own trust store. Always mint role-specific leaves; never reuse one cert for both directions.
The ALB trust store — what verify actually checks
In verify mode the ALB performs X.509 path validation on the presented client certificate: signature chain to a bundle CA, validity period, and revocation against any attached CRLs. It does not match hostnames or subject names — any unexpired, unrevoked certificate under a trusted CA is accepted. That makes CA hygiene the real control: the bundle should contain only the dedicated CA minted for this purpose, never a public root (which would accept any certificate that CA ever signed, issued to anyone). Updating trust is an S3-object + trust-store revision operation — which is why versioning the bundle bucket pays off: rolling back a bad bundle is a pointer flip, not a re-mint.
Certificate lifecycle — the operational tail
ACM-imported certificates do not auto-renew; expiry of the client cert means CloudFront→origin handshakes fail globally and the distribution starts serving 502s at scale — an outage with a precisely predictable date. The runbook for imported certs: mint a new leaf → re-import to the sameACM ARN (re-import preserves the ARN, so no CloudFront change needed) → confirm handshakes → retire the old leaf. On the trust side, the ALB bundle can hold old + new CA simultaneously, enabling zero-downtime CA rotation. Treat the cert expiry date as an SLO and alarm on ACM’s DaysToExpiry metric.
What origin mTLS refuses to do — and what that tells you
The exclusion list is architecturally informative: no gRPC, no WebSocket, no VPC origins, no Lambda@Edge origin triggers. The pattern: origin mTLS lives in CloudFront’s standard HTTP/1.1–HTTP/2 origin-fetch path, and every excluded feature uses different connection machinery. The VPC-origins exclusion is the strategic one — it means “make the ALB internal and unreachable” and “prove identity with mTLS” are currently alternatives, not layers. If your origin can be fully private, VPC origins remove the public attack surface outright; origin mTLS is the answer when the origin must stay publicly resolvable (multi-account, hybrid, third-party origins, or API Gateway). Also note the commercial gate: the feature requires the Business, Premium, or Pay-As-You-Go CloudFront pricing plan.
The origin-protection toolbox compared
| Mechanism | What it proves | Weakness |
|---|---|---|
| Custom header | Caller knows a static secret | Bearer token — leaks, rarely rotated, app-layer check |
| Managed prefix list | Request came from some CloudFront | Includes every other AWS customer's distribution |
| OAC (SigV4) | Signed request from your distribution | S3/Lambda-URL origins only; not ALB/custom origins |
| Origin mTLS | Caller holds your CA-signed private key | HTTP/1.1–HTTP/2 path only; excluded features above |
A defensible production stack for a public ALB origin: security group from the prefix list (network) + origin mTLS (identity) + WAF as backstop — three independent layers, none of which is a static secret.
Security & Cost Posture
Security highlights
- Trust inversion:the origin stores only public material (CA cert in S3, server cert on the listener); CloudFront’s private key lives exclusively inside ACM us-east-1 — never exportable, never in Terraform state, never in a header
- Trust-store S3 bucket: private, versioned, SSE-encrypted, public access fully blocked; the listener is pinned to an exact object version
- Single-purpose CA whose own private key is shredded after minting — structurally caps what a repo or workstation compromise can leak
- Default-deny two-way SG pair, no SSH (SSM Session Manager only), IMDSv2 required, encrypted EBS, least-privilege instance role
Accepted lab risks — and their production preconditions
Two risks are consciously accepted for this disposable lab, each recorded in the spec with an explicit prod-blocking precondition: the origin EC2 sits in a public subnet with a public IP (production requires a private subnet plus SSM/S3 VPC endpoints), and there is no WAF on the internet-facing edge (production requires a WAFv2 web ACL, scope CLOUDFRONT in us-east-1 — mTLS gates only the CloudFront→ALB hop; the viewer edge stays open).
The remaining gap pattern is detective controls: no ALB access logs, no CloudFront access logs, no VPC Flow Logs. The preventive posture is solid (SGs, mTLS, IMDSv2, encryption everywhere), but the lab would be blind in an investigation — a useful reminder that access controls and the evidence trail are designed at the same time, not sequentially.
Cost model
| Item | Config | Est. (8h lab day) |
|---|---|---|
| ALB | 1 ALB, ap-southeast-1, ~0 LCU | ~$0.20 |
| EC2 t4g.micro | on-demand, AL2023 ARM | ~$0.08 |
| EBS gp3 8 GB | encrypted root volume | ~$0.03 |
| S3 / ACM / CFN / VPC / CloudFront | imports are free; PAYG plan | ~$0.00 |
| Total | torn down same day | ≈ $0.35 |
Left running, the lab becomes ≈ $26/month — the ALB alone is 62% of the run-rate, which is why the entire ~$22.51/month savings story is a single lever: teardown discipline. The structural levers: avoiding AWS Private CA (~$400/month — the single biggest decision in the lab design) and same-day terraform destroy. In production the feature itself adds no per-request mTLS surcharge worth modeling; handshake overhead is amortized by connection pooling and the cache.
Takeaways
- Origin mTLS changes the class of origin protection— from “knows a secret / right IP range” to “holds a CA-signed private key” — and the private key never leaves ACM, so origin compromise no longer leaks an impersonation credential.
- EKU is the silent gatekeeper. A client cert minted without
clientAuthnever appears in CloudFront’s certificate picker — bake the EKU check into the mint step, not into debugging time. - ALB verify validates the chain, not the subject.Your trust bundle’s narrowness is the policy — a dedicated single-purpose CA is non-negotiable.
- VPC origins and origin mTLS are alternatives, not layers.Pick “make it private” or “prove identity to it” per origin.
- Terraform intersects provider constraints across every module. A vendored module’s pin silently overrode the environment’s claimed floor — your own
versions.tfis an intention; the cross-module intersection is the contract. - When the provider lags a new AWS feature, quarantine the gap. Isolate the unsupported attribute behind the smallest shim and write the fold-back-into-HCL trigger down, or the workaround becomes permanent.
References
- Amazon CloudFront now supports mTLS authentication to origins — AWS News Blog
- Enable origin mutual TLS for CloudFront distributions — AWS Docs
- Mutual authentication with TLS in Application Load Balancer — AWS Docs
- aws_lb_trust_store — Terraform Registry (origin mTLS on aws_cloudfront_distribution pending at time of writing)
- RFC 5280 — Internet X.509 PKI Certificate and CRL Profile (EKU semantics)