
CloudFront Policy Patterns for Production: SPA+API, JWT Auth, Multi-Tenant SaaS, and Image CDN

The hard part of CloudFront policies isn't picking one off the managed list. It's combining the three policy types — cache, origin request, response headers — for an actual application. The right cache policy for a SPA isn't right for its API. Forwarding Authorization to your origin without poisoning the cache turns out to be impossible with a custom origin request policy. Multi-tenant routing on Host doesn't partition the cache by default, and most tutorials don't mention it.
Four architectures cover most of what teams actually build on CloudFront: a SPA on S3 with an API behind a separate path, an authenticated API with JWT or session cookies, multi-tenant SaaS with Host-based routing, and an image CDN with on-the-fly transformations. Each one has a canonical policy stack, a set of gotchas that bite in production, and one or two places where CloudFront Functions or Lambda@Edge become necessary — because some of these patterns can't be solved with policies alone.
This builds on three earlier deep dives that cover the per-policy fundamentals: cache policies, origin request policies, and response headers policies. I'll assume you've read them or know the equivalents — this article is about combinations, not basics.
Table of contents
- The four CloudFront events and where edge functions fit
- Pattern 1: SPA on S3 + API behind a separate path
- Pattern 2: Authenticated API with JWT or session cookies
- Pattern 3: Multi-tenant SaaS with Host-based routing
- Pattern 4: Image CDN with on-the-fly transformations
- What to read next
The four CloudFront events and where edge functions fit
Every viewer request that reaches CloudFront passes through four lifecycle events: viewer request (when CloudFront receives the request, before the cache lookup), origin request (only on cache miss, before forwarding to origin), origin response (after CloudFront receives the origin's response, before caching it), and viewer response (just before CloudFront sends the response to the viewer). The two origin events fire only on cache misses; the two viewer events fire every time. Cached responses skip both origin events entirely. Per the AWS Lambda@Edge trigger-events docs, CloudFront also skips viewer-response functions when the origin returns HTTP ≥ 400, and skips all four events on automatic HTTP→HTTPS redirects.
Two compute primitives can run code at these events: CloudFront Functions and Lambda@Edge. The choice usually isn't subtle — they're built for different things.
| CloudFront Functions | Lambda@Edge | |
|---|---|---|
| Events supported | viewer request, viewer response | all four |
| Runtime | JavaScript (cloudfront-js-1.0 / 2.0) | Node.js, Python |
| Duration | sub-millisecond CPU budget | up to 30 s |
| Memory | 2 MB | 128 MB+ |
| Network access | No | Yes |
| Request body access | No | Yes (opt-in, request events only) |
| Best for | header rewrites, redirects, JWT signature validation, query-string normalization | dynamic origin selection, SDK calls, generating or replacing responses, request-body inspection |
The biggest practical constraints: CloudFront Functions can't make network calls and only run on viewer events. Neither service can inspect the response body returned by the origin — Lambda@Edge can replace a response body or generate one from scratch in request triggers, but the origin-response trigger doesn't expose the origin's body to the function (you can overwrite it but not read it). For true read-and-transform body manipulation, you need a real origin compute service like a Lambda function URL or S3 Object Lambda. Lambda@Edge is regional, slower than CloudFront Functions, and harder to debug, but it can read request bodies (with the include-body opt-in for POST/PUT methods) and run on all four events. AWS publishes a direct comparison — worth bookmarking.
A 2023 addition matters here: CloudFront KeyValueStore, accessible from CloudFront Functions running the cloudfront-js-2.0 runtime, lets you store configuration data at the edge without redeploying function code. For multi-tenant scenarios where per-tenant config has to be looked up at the edge, this often replaces what would have been a Lambda@Edge function with a DynamoDB call.
Pattern 1: SPA on S3 + API behind a separate path
A single CloudFront distribution with two cache behaviors: /api/* routes to API Gateway, an ALB, or a Lambda function URL; everything else routes to an S3 bucket holding the SPA shell and assets. Each behavior gets its own three-policy stack.
Static path (default * behavior → S3):
| Policy | Choice |
|---|---|
| Cache | Managed-CachingOptimized |
| Origin request | None (or Managed-CORS-S3Origin if cross-origin asset loads) |
| Response headers | Managed-SecurityHeadersPolicy, plus a custom CSP if needed |
API path (/api/* → API Gateway / ALB / Lambda function URL):
| Policy | Choice |
|---|---|
| Cache | Managed-CachingDisabled for fully dynamic, or a custom policy if some endpoints are cacheable |
| Origin request | Managed-AllViewerExceptHostHeader for API Gateway / Lambda function URLs; Managed-AllViewer for ALBs that accept any host |
| Response headers | None if same-origin; Managed-CORS-with-preflight-and-SecurityHeadersPolicy if the SPA loads from a different domain |
That last row catches a lot of teams. If your SPA and API share the distribution domain (the typical "single distribution, two behaviors" topology), browser requests are same-origin and CORS isn't needed at all. The CORS-with-preflight-and-SecurityHeadersPolicy is only required when the SPA is hosted on a different domain than the API — for instance, SPA on app.example.com calling api.example.com. Many tutorials add it reflexively when it's not doing anything.
The custom error response trap
CloudFront supports SPA client-side routing fallback by mapping origin 404s (or 403s) to /index.html with response code 200 — see the AWS re:Post guide on serving SPAs. The standard recipe is two custom error responses: one for 403 (because S3 with Origin Access Control returns 403 for missing objects when the bucket policy lacks s3:ListBucket) and one for 404 (when s3:ListBucket is granted, or when using S3 website endpoints).
The trap: custom error responses are configured at the distribution level, not per cache behavior. A 404 → /index.html rule applied for the SPA also rewrites genuine API 404s to the SPA shell with HTTP 200, which silently breaks every client expecting a real 404 from the API. There are two real fixes:
The cleaner one is a CloudFront Function on viewer-request that rewrites request.uri to /index.html only for paths that have no file extension and don't match /api/*. AWS publishes this exact pattern in the aws-samples/amazon-cloudfront-functions repo. The fallback works deterministically, the API gets to return its own 404s, and you don't need distribution-level custom error responses at all.
The other fix is Lambda@Edge on origin-response, scoped to the S3 cache behavior only — but that's heavier, costs more, and adds a regional dependency to what should be edge logic. The CloudFront Function path is the right call almost every time. Whichever fix you pick, test API 404 and 403 responses explicitly after deploying — silent rewrites are easy to miss until a client complains about always getting HTML back from /api/users/9999.
OAC, not OAI
For the S3 origin, use Origin Access Control rather than the legacy Origin Access Identity. OAC supports SigV4 signing, all S3 regions launched after December 2022, KMS server-side encryption, and dynamic methods like PUT and DELETE. OAI is in maintenance mode. The CloudFront restrict-S3-access docs mark OAC as the current recommendation. Existing distributions on OAI keep working — there's no urgent migration — but new distributions should use OAC.
One more S3-specific constraint to know: caching based on the Authorization header isn't supported for S3 origins, per the CloudFront header-caching docs. If you need per-user authenticated access to S3 content, use signed URLs or signed cookies — that pattern shows up again in the next section.
Pattern 2: Authenticated API with JWT or session cookies
Caching a JWT-authenticated API is where CloudFront's policy model gets uncomfortable. The hard constraint: Authorization cannot be allow-listed in a custom origin request policy. AWS's CloudFormation API rejects it at deploy time. The CDK issue #28883 is the canonical bug report, and the AWS re:Post Q&A on forwarding the Authorization header is the official workaround page.
This means there's no supported way to forward only Authorization to your origin while keeping it out of the cache key. Your options collapse to a small set:
The first is to include Authorization in the cache policy header list, which auto-forwards it to the origin (anything in the cache key is automatically forwarded). The cost is that every distinct token becomes its own cache entry — for a JWT-authenticated app, that's one cache entry per active user. Cache hit ratio approaches zero on shared resources.
The second is to use the managed AllViewer or AllViewerExceptHostHeader origin request policy. Both forward every viewer header, including Authorization, without putting them in the cache key. This works but has a wider attack surface: every cookie, every analytics header, everything the browser sends ends up at your origin.
The third is a CloudFront Function on viewer-request that copies Authorization to a custom header (e.g. X-Internal-Auth). A custom header name can be allow-listed in a custom origin request policy. The function is small — a single line of JavaScript — but the indirection is real and worth flagging in your distribution config.
The minimum-TTL silent leak
Even with the right header configuration, CloudFront's cache policies have a behavior that bites authenticated apps hard. The AWS docs on managed cache policies state directly: when minimum TTL is greater than 0, CloudFront caches content for at least that duration even if the origin returns Cache-Control: no-cache, no-store, or private.
Most managed cache policies have minimum TTL ≥ 1 second. CachingOptimized is 1 s. The Amplify policies are 2 s. Only CachingDisabled has minimum, default, and maximum all set to zero. So if you attach CachingOptimized to an authenticated path and your origin returns Cache-Control: private, max-age=0 to mark the response as per-user, CloudFront caches user A's response for one second and serves it to user B who hits the same edge location during that window. It's a real per-user data leak, and the only signal is in the docs — the API doesn't warn you when you attach CachingOptimized to a behavior that needs private honored.
The fix is either CachingDisabled (minimum TTL = 0, ignoring origin Cache-Control for caching is disabled by definition) or a custom cache policy with MinTTL = 0. For authenticated APIs that should never be cached at the edge, CachingDisabled is the safe default.
Signed URLs for cacheable per-user content
When the content is per-user but cacheable per-URL — think personalized PDFs, downloadable reports, profile-specific images — Authorization headers are the wrong primitive entirely. The signed URLs and signed cookies pattern puts the auth in the URL or cookie, signed with a CloudFront key pair or trusted key group. CloudFront treats Expires, Signature, Key-Pair-Id, and Policy as signed-URL control parameters and removes them before forwarding the rest of the query string to origin; for SHA-256 signed URLs, you also include Hash-Algorithm=SHA256 per the signed URL docs. The practical effect for caching: different users with different signatures requesting the same underlying resource share the same origin response, because the per-user signature isn't part of what reaches origin.
For cacheable, access-controlled content, signed URLs and signed cookies are the pattern AWS documents most directly. If your app fits the shape of "per-resource auth that's cacheable" rather than "per-request auth on dynamic data," signed URLs are usually simpler than anything you can build with the three-policy model.
Lightweight token validation at the edge
CloudFront Functions can validate a JWT signature at viewer-request and reject unauthenticated traffic before it ever reaches your origin. The AWS JWT validation example shows the pattern: parse the token, verify the signature with a hardcoded secret, allow or 401. With the cloudfront-js-2.0 runtime, crypto.createHmac is available, which makes this practical for HS256-signed tokens.
The honest limitation: CloudFront Functions can't fetch JWKS, can't call KMS or Secrets Manager, and can't introspect tokens against an OAuth server. The signing key has to be embedded in the function code or a KeyValueStore entry. For RS256 tokens with key rotation, Lambda@Edge with a JWKS fetch is still the right tool — but it's a meaningfully heavier dependency than a CloudFront Function with KVS.
Pattern 3: Multi-tenant SaaS with Host-based routing
A single distribution serves multiple tenant domains: acme.app.com, widgets.app.com, and a hundred more, all aliased to the same CloudFront distribution and resolving to the same origin. The application reads the Host header to determine which tenant the request belongs to.
The architecture sounds clean, but CloudFront's default behavior breaks it in a way that's easy to miss until production traffic exposes it.
Host is not in the cache key by default
Per the AWS re:Post answer on default cache key composition and the understanding-the-cache-key docs, CloudFront's default cache key consists of the distribution's domain name plus the URL path (plus the OPTIONS method when applicable). The viewer's Host header — the alternate domain name that determined which tenant they were trying to reach — is not part of the cache key unless you explicitly add it.
This means that without configuration, all tenant domains aliased to the same distribution share the same cache namespace. The first tenant to populate /index.html poisons it for everyone else. A request to acme.app.com/dashboard and a request to widgets.app.com/dashboard look identical to CloudFront's cache, and the second tenant's user gets the first tenant's response.
The fix is to attach a custom cache policy with Host explicitly added to the headers list. Once Host is in the cache key, the cache partitions correctly by tenant. This is the single most important configuration step for a multi-tenant CloudFront distribution, and it's documented exactly nowhere in the managed-policy reference — because none of the managed cache policies include Host.
HostHeaderOnly origin request policy
If your origin needs the viewer's Host for its own tenant routing — a multi-tenant ALB, an EKS ingress that maps Host to namespace, a reverse proxy doing virtual hosts — the Managed-HostHeaderOnly origin request policy forwards just the Host header and nothing else. Per the managed origin request policies docs, it's the inverse of AllViewerExceptHostHeader.
Be careful not to confuse the two. AllViewerExceptHostHeader strips Host and forwards everything else, used when origin is API Gateway and shouldn't see the viewer's domain. HostHeaderOnly forwards Host and nothing else, used when origin needs Host for routing and you want a clean origin request without cookies or query strings. Pairing the right one with the right cache policy is the whole pattern.
Per-tenant response headers can't be done with policies
Response headers policies are configured per cache behavior, not per request. Cache behaviors match on path patterns, not on Host. So a single cache behavior shared across tenants gets one response headers policy — every tenant receives identical CSP, identical security headers, identical CORS. There's no built-in way to vary response headers by tenant.
If your tenants need different CSPs (one allowlists Stripe, another allowlists PayPal), different CORS origins, or different security headers, you need a CloudFront Function on viewer-response that reads the request's Host and rewrites the response headers. With CloudFront KeyValueStore for per-tenant config lookup, this is a practical pattern — define the per-tenant headers as KVS entries keyed by tenant domain, look them up in the function, and rewrite. AWS's aws-samples/sample-cloudfront-saas-manager-nginx-ingress-migration repo demonstrates the broader pattern.
One caveat from the docs: viewer-response functions don't fire when the origin returns HTTP ≥ 400. Your error responses won't get the per-tenant header treatment. For a tenant-specific 500 page, you need either custom error responses pointing to per-tenant content (configured at the distribution level — ouch, see Pattern 1) or Lambda@Edge on origin-response.
CloudFront SaaS Manager and multi-tenant distributions
In April 2025, AWS announced the general availability of CloudFront SaaS Manager, which introduces a new distribution type: the multi-tenant distribution. A multi-tenant distribution is a template defining shared origins, cache behaviors, and policies; each distribution tenant represents a domain. Per the multi-tenant distribution docs, tenants can override AWS WAF web ACLs, TLS certificates, and geographic restrictions individually.
This solves the certificate scaling problem that previously capped multi-tenant designs at the alternate domain name limit. Standard distributions allow 100 alternate domain names per distribution by default (requestable for increase), with hard ALB-side limits when using ALBs as origins. With multi-tenant distributions, the quotas shift: the default limit is 10,000 distribution tenants per AWS account (across up to 20 multi-tenant distributions per account), with up to 100 aliases per tenant. Per the tenant certificate docs, a tenant inherits the shared ACM certificate from the multi-tenant distribution by default — or, for tenants that need their own custom domain, you can attach a tenant-level managed CloudFront certificate that ACM provisions and renews automatically.
What SaaS Manager does not change is the policy story. Cache, origin request, and response headers policies are still attached at the multi-tenant distribution (template) level, and they're not tenant-overridable in the current GA. Per-tenant response headers still require a CloudFront Function. The "Host not in cache key by default" rule still applies. SaaS Manager is an operational and certificate-scaling improvement, not a policy-model change.
If you're starting a new multi-tenant CloudFront deployment in 2026, SaaS Manager is the right path. If you're maintaining an existing multi-tenant distribution that hasn't hit certificate or CNAME limits, there's no urgent reason to migrate — the underlying policy model is the same.
Pattern 4: Image CDN with on-the-fly transformations
Query strings drive the transform: /photo.jpg?w=300&format=webp returns a 300px-wide WebP version of the original. The naive policy combination breaks two ways: caching by "all query strings" fragments the cache by every UTM and fbclid, and naive Accept header forwarding fragments it by every browser's distinct Accept string.
AWS publishes a reference implementation for this pattern that's worth reading in full. The architecture: S3 stores transformed images with a TTL lifecycle policy, a Lambda function URL with OAC handles cache misses by transforming the image and writing it back to S3, and CloudFront uses Origin Failover — primary origin is the S3 bucket, secondary is the Lambda function URL. On a cache miss, CloudFront tries S3 first, gets a 403 if the transformed image doesn't exist, and falls back to the Lambda function URL. The Lambda transforms, writes to S3, and returns. Subsequent requests for the same transform hit S3 directly.
The complete sample is at aws-samples/image-optimization — CDK code plus the Lambda function. The AWS-published Dynamic Image Transformation for CloudFront solution is the heavier production-grade variant.
The cache policy: whitelist, not all
The cache policy whitelists exactly the transform parameters: w, h, format, q, and whatever else your URL scheme supports. Setting query string behavior to all is the cardinal mistake — it adds tracking parameters (utm_*, fbclid, gclid, etc.) to the cache key, fragmenting the cache by analytics noise.
To enforce canonical URLs at the edge, the standard pattern is a CloudFront Function on viewer-request that sorts query parameters alphabetically, drops anything not on the allow-list, and lowercases values. AWS publishes this exact function as an example in the docs. The result: ?w=300&format=webp and ?format=webp&w=300&utm_source=email produce the same cache key.
Accept vs Accept-Encoding: only one is normalized
Modern browsers send distinct Accept headers for image requests — image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8 and dozens of variants. If you put Accept in your cache policy header list to support WebP/AVIF content negotiation, every variant fragments the cache.
CloudFront has dedicated, normalized handling for Accept-Encoding (the cache policy's EnableAcceptEncodingGzip and EnableAcceptEncodingBrotli flags collapse Accept-Encoding to a small set of values before adding it to the cache key, per the cache policy docs). It does not do this for Accept. Putting Accept in the cache key directly fragments by every distinct viewer string.
The working pattern is to inspect Accept in a CloudFront Function on viewer-request, decide whether the viewer supports image/webp or image/avif, and rewrite the URL to add a canonical ?format=webp or ?format=avif parameter. That parameter — with low cardinality (2-4 distinct values) — goes in the cache key. The raw Accept header doesn't.
One related warning from the same docs page: if the cache policy has EnableAcceptEncodingGzip or EnableAcceptEncodingBrotli enabled, do not add Accept-Encoding to the origin request policy. CloudFront automatically forwards the normalized header on origin requests when those flags are set, and adding it to the origin request policy has no effect.
Lambda function URL with OAC, not Lambda@Edge
The current AWS reference architecture uses Lambda function URLs with Origin Access Control, not Lambda@Edge, for image transformation work. This is a 2023-2024 change worth flagging because most older tutorials still recommend Lambda@Edge.
The reasons: Lambda function URLs with OAC are private (only signed CloudFront requests can invoke them), execute in a single region with full SDK access including S3 writes, and — critically — function URLs are origins, which means they can sit in an origin group as the secondary failover target. Lambda@Edge is a hook that runs on origins, not an origin itself, so the failover-to-transformer pattern requires a real second origin to attach the hook to.
There's still a place for Lambda@Edge in image processing — if you specifically need request-body access (e.g. for image upload transformations), Lambda@Edge on viewer-request with the body opt-in is the only way. But for read-side resize work, the function URL pattern is the modern recommendation.
One related gotcha worth flagging: when OAC is enabled with SigningBehavior: always, CloudFront overwrites the viewer's Authorization header with its own SigV4 signature. If you need the viewer's Authorization to reach a Lambda function URL origin protected by OAC, you have to put Authorization in the cache policy header list — the same workaround as Pattern 2. The other option is to copy Authorization to a non-standard header like X-Authorization in a CloudFront Function before OAC takes over the standard one. The image-CDN pattern usually doesn't need viewer auth, but it's the same trap that bites APIs behind function URLs with OAC.
What to read next
Cache, origin request, and response headers policies are the foundation of most CloudFront configurations. The production patterns above show where those policies are enough on their own, and where they need to be combined with other CloudFront features — CloudFront Functions, Lambda@Edge, Origin Access Control, custom error responses, Origin Failover, signed URLs, or SaaS Manager. That is usually where the interesting design work starts: not in choosing one policy, but in understanding how the policies interact with the rest of the distribution.
If one of these patterns matches what you're building, the next step is to read the relevant AWS source directly. The CloudFront Functions documentation is worth reading if you're doing URI rewrites, header manipulation, JWT checks, or query normalization at the edge. For multi-tenant SaaS, start with the CloudFront SaaS Manager announcement and the multi-tenant distribution docs. For image transformation, the aws-samples/image-optimization repo is the most useful starting point.
CloudFront has a lot of old tutorials, partial examples, and conflicting advice floating around. I’ve tried to keep the patterns here tied to current AWS behavior and primary sources, but the safest habit is the same one you should use for any edge configuration: verify the specific feature, quota, and policy interaction against the AWS docs before you ship it.
Frequently Asked Questions
Why can't I forward the Authorization header to my origin without breaking my CloudFront cache?
AWS blocks Authorization from being allow-listed in a custom origin request policy, so it can only be forwarded by adding it to the cache policy (which makes it part of the cache key) or by using the AllViewer/AllViewerExceptHostHeader managed policies. A CloudFront Function that copies Authorization into a custom header before the origin request is the common workaround when neither option is acceptable.
How do I stop CloudFront from serving one tenant's cached page to another tenant?
By default, CloudFront's cache key is just the distribution domain and URL path — the Host header isn't included, so multiple tenant domains aliased to one distribution share the same cache. Attach a custom cache policy with Host added to the headers list to partition the cache correctly per tenant.
What's the difference between CloudFront Functions and Lambda@Edge?
CloudFront Functions run only on viewer request/response events, have a sub-millisecond CPU budget, and can't make network calls — they're built for lightweight tasks like header rewrites or JWT signature checks. Lambda@Edge runs on all four CloudFront events, supports up to 30 seconds of execution and full SDK/network access, but is regional and slower to invoke.
Why does CloudFront's CachingOptimized policy ignore my origin's Cache-Control: private header?
CachingOptimized has a minimum TTL of 1 second, and CloudFront caches a response for at least that duration even when the origin marks it no-cache, no-store, or private. For authenticated or per-user responses, use CachingDisabled or a custom cache policy with minimum TTL set to 0.
Should I use Origin Access Control (OAC) or Origin Access Identity (OAI) for an S3 origin?
Use OAC for any new distribution — it supports SigV4 signing, all S3 regions, KMS encryption, and dynamic methods like PUT and DELETE, and AWS lists it as the current recommendation. OAI still works on existing distributions, but it's in maintenance mode with no reason to adopt it for new work.
How do I make a single-page app's client-side routes work with CloudFront without breaking API 404s?
Custom error responses that rewrite 404s to index.html are configured at the distribution level, so they also rewrite genuine API 404s to a 200 HTML response. The cleaner fix is a CloudFront Function on viewer-request that rewrites only extensionless, non-API paths to index.html, leaving the API's own error responses untouched.
What is CloudFront SaaS Manager and do I need it for a multi-tenant architecture?
CloudFront SaaS Manager introduces multi-tenant distributions, where a single template can support up to 10,000 distribution tenants with per-tenant certificates, WAF rules, and geo restrictions — solving the alternate-domain-name scaling limits of standard distributions. It doesn't change the underlying policy model: cache, origin request, and response headers policies are still set at the template level and aren't tenant-overridable.
Why does caching all query strings break an image CDN built on CloudFront?
Setting a cache policy's query string behavior to 'all' adds every tracking parameter (utm_source, fbclid, gclid, and similar) to the cache key, fragmenting the cache for what should be the same transformed image. Whitelisting only the actual transform parameters — width, height, format, quality — keeps the cache key stable.
Can I use signed URLs instead of the Authorization header for per-user cached content on CloudFront?
Yes, and for cacheable per-user content it's usually the better option. Signed URLs and signed cookies keep the per-user signature out of what's forwarded to the origin, so different users requesting the same underlying resource can still share a single cached origin response.
Should image transformations on CloudFront run on Lambda@Edge or a Lambda function URL?
AWS's current reference architecture uses a Lambda function URL with Origin Access Control as a secondary origin in an Origin Failover setup, because function URLs can act as a real origin with full SDK access. Lambda@Edge is still the right choice only if you specifically need to read the request body, such as for image upload transformations.
Building or auditing AWS edge infrastructure?
u11d works with engineering teams on AWS architecture end to end, from CDN and edge configuration to full infrastructure reviews. If CloudFront is one piece of a bigger AWS setup you'd like assessed, let's talk.






