Skip to main content
thanhpham.site
SecurityArchitectureJun 26, 2026  ·  13 min read
TerraformTerraformCloudFrontCloudFrontS3S3OpenSSL

CloudFront Viewer mTLS — Verifying the Client at the Edge

An API key in a header is a bearer token waiting to leak; an IP allowlist proves the network, not the caller. CloudFront viewer mTLS validates a client certificate at the edge, during the TLS handshake — so an untrusted client is rejected at the door, globally, before any compute runs. Here’s the trust model, the trade-offs, and a native-Terraform lab. It’s the mirror image of origin mTLS — together, trust goes both ways.

The Problem

The companion feature answered “how does the originknow a request really came from CloudFront?” Viewer mTLS answers the mirror question: how does CloudFront — and the app behind it — know the client is who it claims to be, cryptographically, at the edge, before spending any backend compute?

The standard answers are the same compromises, one layer up. An API key / bearer tokenin a header is a static secret that leaks the way a shared-secret origin header did — copyable from a log line, a proxy, a phone, an ex-employee’s notes — and rotating it across a fleet of callers is a coordinated, therefore rare, event. An IP allowlist / WAF rule proves network provenance, not identity, and is brittle for roaming clients or device fleets. Application-layer auth only fires afterthe request has already traversed CloudFront, the cache, and the origin handshake — you pay full transit cost just to say “no”.

Before November 2025 you could not terminate client-certificate authentication at CloudFront at all. You either pushed mTLS down to an ALB or API Gateway behindthe distribution — losing the edge, because an untrusted client still transits the CDN and burns an origin handshake before rejection — or bolted a token check onto a CloudFront Function, re-creating the bearer-token pattern. AWS then shipped viewer mTLS: CloudFront validates the viewer’s X.509 client certificate against a trust store (a CA bundle you control) at the edge POP, before the request is cached or forwarded.

This post analyses the feature from an architect’s seat — the trust model, where it sits relative to API keys and WAF, the three enforcement modes, the genuinely hard revocation-at-global-scale problem, and the operational cost that actually matters (distributing and rotating client certs across a fleet, not the CloudFront config) — using a deliberately minimal lab: client → CloudFront (viewer mTLS, mode = required) → a private S3 origin via OAC, with a self-signed CA issuing one client leaf.

Architecture & Data Flow

The whole lab proves one boundary: the client→CloudFront hop is mutually authenticated at the edge, so a request without a trusted client certificate is rejected during the TLS handshake — globally, before any compute, cache, or origin fetch. Everything else is two tiny S3 buckets — the cheapest possible scaffolding to observe that handshake.

text
Client (holds client certificate + key)
   │
   │  ① TLS handshake — presents client certificate
   ▼
CloudFront distribution     ──②  validate cert chain ──▶   CloudFront Trust Store
viewer_mtls_config=required       at the edge POP                │
(GLOBAL)                                                         │ ingests + pins
   │                                                             │ CA-bundle version
   │  ③  OAC-signed (SigV4) origin fetch                         ▼
   ▼                                                      S3 CA-bundle bucket
S3 origin bucket (private, OAC-only) ──▶ index.html      (ca-bundle.pem · versioned · SSE)

No valid client cert + mode=required  ⇒  TLS handshake REJECTED at the edge
(the request is never cached, forwarded, or billed past the rejected connection).

Solid numbered arrows ①–③ are the request path; the dashed arrow is the trust store ingesting and version-pinning its CA bundle. CloudFront and the trust store are global; the two S3 buckets are regional. There is no VPC, ALB, or EC2.

Phase 1 — Offline PKI bootstrap (one-shot, before the first plan)

A script mints a self-signed root CA (P-256, 7-day) and signs one leaf with EKU clientAuth — the viewer’s identity; a build-time assertion rejects an over-scoped leaf that also claims serverAuth. Only the public CA certificate (no private key) is uploaded to S3 as the trust source; the client leaf + key go to the caller out-of-band; the CA private key is shreded after signing, so the trust store is immutable. Unlike origin mTLS, there is no ACM import — the trust store ingests the bundle straight from S3. Private keys never enter the repo or Terraform state.

Phase 2 — The trust store and the gate (native HCL)

The trust store is built from the S3 bundle, pinned to an exact object versionso overwriting the bucket can’t silently change what the edge trusts. The distribution’s viewer_mtls_config turns the gate on. The whole thing is native Terraform — aws_cloudfront_trust_store and viewer_mtls_config shipped in the AWS provider within ~2 months of launch, so there is no CloudFormation escape-hatchhere (the contrast with the origin-mTLS lab is the whole story of the “Provider timing” decision below).

hcl
# modules/cloudfront_viewer_mtls/main.tf — pure native HCL, no escape-hatch.
resource "aws_cloudfront_trust_store" "this" {
  name = "${var.name_prefix}-trust-store"

  ca_certificates_bundle_source {
    ca_certificates_bundle_s3_location {
      bucket  = aws_s3_bucket.ca_bundle.id
      key     = aws_s3_object.ca_bundle.key
      region  = var.aws_region
      version = aws_s3_object.ca_bundle.version_id  # pin exact version — no silent re-trust
    }
  }
}

resource "aws_cloudfront_distribution" "this" {
  # ... origin (private S3 via OAC), default_cache_behavior ...

  # The point of the lab: the edge REJECTS any handshake whose client cert
  # does not chain to the trust-store CA — globally, before any compute.
  viewer_mtls_config {
    mode = "required"   # required | optional | passthrough
    trust_store_config {
      trust_store_id                 = aws_cloudfront_trust_store.this.id
      advertise_trust_store_ca_names = true
      ignore_certificate_expiry      = false
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true   # default *.cloudfront.net cert
  }
}

Phase 3 — The runtime handshake

  1. A viewer opens TLS to the nearest CloudFront POP and presents its client certificate.
  2. CloudFront validates the chain against a trust-store CA at the edge. With mode = required, a missing or untrusted certificate fails the handshake outright — the request is never cached, forwarded, or billed past the rejected connection.
  3. On success, CloudFront performs an OAC-signed (SigV4) fetch from the private S3 origin and returns the object. The bucket policy grants s3:GetObject only to this distribution’s OAC principal (scoped by AWS:SourceArn) — the origin is never publicly reachable.
bash
DOMAIN=$(terraform output -raw cloudfront_domain_name)

# With a cert that chains to the trust-store CA → 200 + the page from S3
curl --cert certs/client.pem --key certs/client.key "https://$DOMAIN/"

# Without a client cert → TLS handshake REJECTED at the edge (never reaches the origin)
curl "https://$DOMAIN/"

The boundary worth noticing: client identity is enforced at the TLS layer, at a globally distributed edge, before any layer-7 logic or compute runs — the earliest and most widely distributed enforcement point AWS offers.

Validation status

This lab is fully codified and passed the complete local validation chain (fmt → validate → tflint → checkov → trivy, 0 HIGH/CRITICAL) on the native provider — but it has not been applied yet (no AWS credentials in the build run). The success criterion at apply time is the edge handshake: curl with the client cert succeeds, curl without it is rejected mid-handshake.

Design Decisions

Viewer mTLS over API keys — identity, not a bearer token

A key or token is a static bearer credential whose secrecy degrades over time and whose rotation is a fleet-wide coordinated change. A client certificate is asymmetric: the private key never leaves the client and never transits the wire; CloudFront stores only the public CA. Compromising the edge or the trust bucket reveals nothing that lets an attacker impersonate a client. Configuration errors fail closed (handshake rejection) — operationally louder, which is exactly right for an authentication control.

mode = required vs optional vs passthrough

required is a hard edge gate — no valid cert, no connection. optional accepts both cert and non-cert viewers and forwards cert metadata for the app to decide — ideal for gradual rollout or a mixed audience. passthroughforwards the presented cert without validating — which re-creates the “app must check something” pattern and is rarely what you want. The lab uses required; because it locks out every non-cert client on that distribution, segregate public vs authenticated traffic across separate distributions in production.

Self-signed CA over AWS Private CA

Same lab/prod split as origin mTLS: a self-signed CA costs $0 and teaches the trust-store mechanics explicitly; ACM Private CA (~$400/month) is the production answer for managed lifecycle and renewal. The trust store validates the chain, not the subject, so CA hygiene is the real control — the bundle holds only the single-purpose CA, never a public root.

S3 + OAC origin over an ALB

Viewer mTLS terminates at CloudFront, so the origin is irrelevant to the demonstration — which makes S3 + OAC the cheapest witness and removes any run-rate (the origin-mTLS lab’s ALB was 62% of its cost). It also keeps the two labs cleanly orthogonal: this one isolates the viewer leg, the sibling the origin leg, and they compose without entanglement.

Provider timing — no escape-hatch this time

Origin mTLS shipped with the Terraform AWS provider ~7 months behind launch, forcing the sibling lab to quarantine its distribution in an aws_cloudformation_stack. Viewer mTLS instead went native within ~2 months (aws_cloudfront_trust_store + viewer_mtls_config), so this lab is pure HCL — no CFN, no cfn-lint, no AWSCC hybrid. The lesson is not “Terraform always lags” but the opposite: provider lag is feature- and time-specific — check the CHANGELOG before assuming you need a workaround.

Revocation is the hard part — design it in, don't defer it

A globally distributed validator can’t cheaply consult a central CRL on every handshake. The viable patterns: the trust store’s OCSP integration, or a CloudFront Functions + KeyValueStore serial denylist checked post-handshake. “We’ll add revocation later” is how a leaked client key stays valid for months; for high-assurance fleets, short-lived certs (issue often, expire fast) are frequently a cleaner answer than standing revocation infrastructure.

Cert distribution & lifecycle is the real cost — not the config

For origin mTLS the “client” was CloudFront itself: one identity, in ACM, never leaving AWS. For viewer mTLS the clients are external and many — partners, browsers, devices. You now own secure delivery of each client’s cert + key, per-client expiry tracking, and revocation at fleet scale. The distribution config is a dozen lines; the PKI operations are the project.

Extended Knowledge

Trust store mechanics at the edge

The trust store is a CloudFront-level object built from a PEM CA bundle in S3. Each edge POP performs X.509 path validation on the presented client certificate — signature chain to a bundle CA, validity period, and (if configured) revocation — and does not match subject or hostname, so any unexpired, unrevoked certificate under a trusted CA is accepted. Two knobs shape behaviour: advertise_trust_store_ca_names makes CloudFront list the trusted CA names in the TLS CertificateRequest so a client holding several certs presents the right one; ignore_certificate_expiry is an escape hatch for clock-skew scenarios and should normally stay false. Versioning the bundle bucket means rolling back a bad bundle is a pointer flip, not a re-mint.

The “trust both ways” matrix

Five controls, each answering a different question. Crucially, viewer mTLS is authentication of the connection’s client identity, not authorization: it tells you who connected, never what they may do. Pair it with WAF and application authz.

ControlQuestion it answersDirection
Viewer mTLSWho is the client connecting to the edge?client → CloudFront
Origin mTLSIs the caller really CloudFront's cert holder?CloudFront → origin
OAC (SigV4)Signed request from your distribution?CloudFront → S3
WAFIs this request shape/volume allowed?layer 7 filter
Cognito / L@EIs this user authorized for this action?application layer

The complete bidirectional design is viewer mTLS + (origin mTLS or OAC) — a mutually authenticated path front to back, with no static secret on the wire.

Revocation at global scale

OCSP stapling pushes freshness to the client/CA; a CRL is awkward to consult per handshake across hundreds of POPs; a serial denylist in CloudFront KeyValueStore (read by a CloudFront Function right after the handshake) gives near-real-time revocation at the cost of an extra moving part. The deeper call is often to avoid standing revocation infrastructure entirely by issuing short-lived client certificates and re-enrolling frequently — revocation latency stops mattering when certs expire in hours. Treat the client-cert rotation cadence as an SLO with its own alarms.

Where viewer mTLS is not the answer

It authenticates a connection’s client identity; it does not authorize actions, does not replace session management, and does not help when the client is an anonymous public browser (you can’t enroll the whole internet with certs). It shines for closed audiences— B2B partners, device fleets, internal service-to-edge calls — where you control enrollment. A frequent design error is treating “presented a valid cert” as “is fully authorized”; the cert identifies the principal, and a separate policy still decides scope. The feature also requires a paid CloudFront plan (Business / Premium / PAYG), not the Free plan.

Security & Cost Posture

Security highlights

  • Identity at the edge: client identity is verified at the TLS layer, before cache or compute — the earliest, most globally distributed enforcement point available; the private key never leaves the client
  • Both S3 buckets: private, SSE-encrypted, versioned, public access fully blocked; the origin is OAC-only (bucket policy scoped by AWS:SourceArn); the CA bundle is pinned to an exact object version
  • Single-purpose CA whose private key is shredded after signing — only the public CA cert is uploaded; keys never enter Terraform state or the repo
  • Forwarded cert-metadata headers (when used) must be edge-trusted-only — strip any client-supplied lookalike, or a client forges its own identity claim

Accepted lab risks — and their production preconditions

Recorded in the spec with explicit prod-blocking preconditions: a self-signed CA (production → ACM Private CA or a third-party CA with a renewal runbook + a DaysToExpiry alarm); no WAF on the edge (production → a WAFv2 web ACL, scope CLOUDFRONT in us-east-1); no revocation (production → OCSP, a KeyValueStore denylist, or short-lived certs); and no access / real-time logs — the same detective-controls blind spot the origin lab flagged. The preventive posture is solid; the evidence trail is the production gap.

Cost model

The headline is the contrast with the origin-mTLS sibling: no ALB, no EC2, so effectively no fixed run-rate. The footprint is CloudFront request/transfer pennies + a tiny S3 cost + the trust store (no hourly charge). An 8-hour lab day rounds to a few cents, and even left running there is no ALB/EC2 drip — teardown is hygiene, not financial urgency. The structural levers are unchanged: avoiding AWS Private CA (~$400/month) with a self-signed CA, and the paid CloudFront plan as a flat prerequisite, not a per-request surcharge.

Takeaways

  1. Viewer mTLS moves client identity to the edge — verified at the TLS handshake in a global POP, before cache or compute; untrusted clients are rejected at the door, everywhere at once.
  2. “Trust goes both ways” is literal: viewer mTLS (client → CF) composed with origin mTLS (CF → origin) yields a fully mutually-authenticated path with no static secret on the wire.
  3. The CloudFront config is the easy 10%; distributing, rotating, and revoking client certs across an external fleet is the real 90% — design revocation before you ship.
  4. mode = required is a distribution-wide gate — segregate public vs authenticated audiences across distributions, or roll out via optional first.
  5. Provider lag is feature- and time-specific: viewer mTLS was native within ~2 months of launch (no escape-hatch), where origin mTLS forced a CFN quarantine for months — read the CHANGELOG before assuming.
  6. A valid client cert is authentication, not authorization— pair viewer mTLS with WAF and application authz; never let “has a cert” mean “can do anything”.

References

Have a topic in mind?

Always up for a good technical conversation.

Suggest a topic, share a war story, or ask the question — happy to dig into the details.