CloudFront Response Headers Policy for Immutable Fingerprinted Assets
Your build emits app.a1b2c3d4.js and every one of those objects should leave the edge with Cache-Control: public, max-age=31536000, immutable — but a third of them are missing it, because the header was baked into S3 object metadata at upload time and one deploy used the wrong aws s3 sync flags. Fixing that at the origin means re-uploading thousands of objects. Fixing it at the CDN means editing one policy.
This page covers the CloudFront response-headers policy: what it controls that a cache policy does not, when the Override flag beats an S3 object’s own metadata, how to split behaviours so /assets/* gets a year and /*.html gets sixty seconds, and the CORS headers that fonts and Subresource Integrity checks need in order to load at all.
A Cache Policy and a Response-Headers Policy Do Different Jobs
The two are attached to the same cache behaviour and are constantly confused, but they act on opposite halves of the request.
A cache policy shapes what CloudFront stores. It decides which parts of the viewer request become part of the cache key — query strings, headers, cookies — and it sets MinTTL, DefaultTTL, and MaxTTL, which bound how long the edge keeps an object. Nothing in a cache policy is ever visible to the browser.
A response-headers policy shapes what the browser receives. It adds, or overrides, headers on the response as it leaves the edge, regardless of whether that response came from cache or from the origin. It has no effect on the cache key and no effect on how long CloudFront retains the object.
That last point is the operational payoff. Because the header is applied on the way out rather than stored with the object, editing the policy changes the header on every cached copy at every POP immediately — no re-upload, and no invalidation of the assets. This is the one legitimate way to fix a Cache-Control mistake across a whole prefix without touching S3 or paying for a purge, which is why it belongs alongside the rest of the CloudFront invalidation workflow.
| Policy type | What it controls | Precedence |
|---|---|---|
| Cache policy | Cache-key inputs (query strings, headers, cookies) and MinTTL / DefaultTTL / MaxTTL |
Wins over origin Cache-Control for edge retention whenever MinTTL is above 0 |
| Origin request policy | Which parts of the viewer request are forwarded to S3 or your custom origin | Independent — affects only the origin fetch, never the browser |
| Response-headers policy | Headers on the response to the viewer: Cache-Control, CORS, security, custom |
With Override: true, wins over the origin’s header; with Override: false, yields to it |
| S3 object metadata | The Cache-Control and Content-Type stored with the object at upload |
Baseline. Beaten by a response-headers policy only when Override is true |
The subtlety worth internalising: a response-headers policy changes what the browser caches, while the cache policy changes what the edge caches. Setting max-age=31536000 in a response-headers policy does not make CloudFront hold the object longer if MaxTTL is 300 — the browser is told a year and the edge still evicts after five minutes. Both halves have to agree, which is covered from the TTL side in Cache-Control immutable and TTL tuning.
Managed Policies Do Not Set Cache-Control
CloudFront ships four managed response-headers policies, and it is worth knowing up front that none of them touch Cache-Control:
Managed-SimpleCORS(60669652-455b-4ae9-85a4-c4c02393f86c) —Access-Control-Allow-Origin: *for simple requests only.Managed-CORS-With-Preflight(5cc3b908-e619-4b99-88e5-2cf7f45965bd) — addsOPTIONSpreflight handling.Managed-SecurityHeadersPolicy(67f7725c-6f97-4210-82d7-5512b31e9d03) — HSTS,X-Content-Type-Options, frame options, referrer policy.Managed-CORS-and-SecurityHeadersPolicy(e61eb345-f2af-4640-8bfa-e00ee4a63d24) — the previous two combined.
So for immutable fingerprinted assets you always need a custom policy. The mechanism is CustomHeadersConfig: a list of literal header name/value pairs, each with its own Override boolean.
What the Override Flag Decides
Override answers a single question: when the origin already sent this header, who wins?
Override: true is the right default for fingerprinted assets. The whole point of a content-hashed URL is that its bytes can never change, so the correct Cache-Control is a property of the path pattern, not of any individual object. Making the CDN authoritative means a partial or misconfigured upload cannot produce a weaker header than intended, and a header correction is a one-line policy edit rather than a bulk --metadata-directive REPLACE pass over the bucket.
Leave Override: false when the origin genuinely knows better per object — an application server computing per-user freshness, or a bucket where some objects are deliberately short-lived. For a pure static-asset prefix, that case does not arise.
Splitting Behaviours by Path Pattern
One policy is not enough, because HTML and hashed assets want opposite headers. CloudFront evaluates cache behaviours in the order they are defined and falls through to the default behaviour when nothing matches.
Creating the policies with the AWS CLI
Two policies: one for hashed bundles, one for fonts that adds CORS. Both are complete and runnable as written.
#!/usr/bin/env bash
set -euo pipefail
# 1. Immutable policy for content-hashed JS, CSS and images.
IMMUTABLE_ID=$(aws cloudfront create-response-headers-policy \
--response-headers-policy-config '{
"Name": "immutable-assets",
"Comment": "Cache-Control for content-hashed build output",
"CustomHeadersConfig": {
"Quantity": 2,
"Items": [
{
"Header": "Cache-Control",
"Value": "public, max-age=31536000, immutable",
"Override": true
},
{
"Header": "Timing-Allow-Origin",
"Value": "*",
"Override": true
}
]
}
}' \
--query "ResponseHeadersPolicy.Id" \
--output text)
echo "immutable-assets policy: $IMMUTABLE_ID"
# 2. Same headers plus CORS, for fonts and any subresource loaded
# with crossorigin="anonymous".
CORS_ID=$(aws cloudfront create-response-headers-policy \
--response-headers-policy-config '{
"Name": "immutable-cors",
"Comment": "Immutable assets that are fetched cross-origin",
"CustomHeadersConfig": {
"Quantity": 1,
"Items": [
{
"Header": "Cache-Control",
"Value": "public, max-age=31536000, immutable",
"Override": true
}
]
},
"CorsConfig": {
"AccessControlAllowOrigins": {
"Quantity": 1,
"Items": ["https://www.example.com"]
},
"AccessControlAllowHeaders": {
"Quantity": 1,
"Items": ["*"]
},
"AccessControlAllowMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"]
},
"AccessControlAllowCredentials": false,
"AccessControlExposeHeaders": {
"Quantity": 1,
"Items": ["Content-Length"]
},
"AccessControlMaxAgeSec": 86400,
"OriginOverride": true
}
}' \
--query "ResponseHeadersPolicy.Id" \
--output text)
echo "immutable-cors policy: $CORS_ID"
OriginOverride: true inside CorsConfig is the CORS equivalent of the per-header Override flag: it tells CloudFront to replace any Access-Control-Allow-Origin the origin sent rather than pass it through. S3 will emit its own CORS headers if the bucket has a CORS configuration, and two conflicting Access-Control-Allow-Origin values are worse than one.
The same setup in CloudFormation
Resources:
ImmutableAssetsHeaders:
Type: AWS::CloudFront::ResponseHeadersPolicy
Properties:
ResponseHeadersPolicyConfig:
Name: immutable-assets
Comment: Cache-Control for content-hashed build output
CustomHeadersConfig:
Items:
- Header: Cache-Control
Value: "public, max-age=31536000, immutable"
Override: true
- Header: Timing-Allow-Origin
Value: "*"
Override: true
HtmlShortTtlHeaders:
Type: AWS::CloudFront::ResponseHeadersPolicy
Properties:
ResponseHeadersPolicyConfig:
Name: html-short-ttl
Comment: Entry documents must revalidate quickly
CustomHeadersConfig:
Items:
- Header: Cache-Control
Value: "public, max-age=60, must-revalidate"
Override: true
SiteDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
DefaultRootObject: index.html
Origins:
- Id: s3-assets
DomainName: !Sub "${AssetBucket}.s3.${AWS::Region}.amazonaws.com"
S3OriginConfig: {}
OriginAccessControlId: !Ref AssetOac
CacheBehaviors:
- PathPattern: /assets/*
TargetOriginId: s3-assets
ViewerProtocolPolicy: redirect-to-https
Compress: true
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
ResponseHeadersPolicyId: !Ref ImmutableAssetsHeaders
DefaultCacheBehavior:
TargetOriginId: s3-assets
ViewerProtocolPolicy: redirect-to-https
Compress: true
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
ResponseHeadersPolicyId: !Ref HtmlShortTtlHeaders
658327ea-f89d-4fab-a63d-7e88639e58f6 is the managed CachingOptimized cache policy, which has MinTTL 0 and MaxTTL 31536000 — so the edge honours whatever Cache-Control the response-headers policy emits, and the two halves agree.
CORS Headers for Fonts and Subresource Integrity
Two categories of asset need more than Cache-Control, and both fail in ways that look like caching bugs.
Web fonts are always fetched in CORS mode by the CSS Font Loading rules, even same-origin, whenever @font-face resolves to a different origin than the stylesheet. Serving fonts from a CloudFront distribution while the page comes from your apex domain is exactly that case. Without Access-Control-Allow-Origin the font request succeeds at the network layer and is then discarded by the browser, so the text silently falls back and your curl check looks perfectly healthy.
Subresource Integrity has the same shape. A <script> or <link> carrying an integrity attribute must also carry crossorigin="anonymous", and the response must then include Access-Control-Allow-Origin. If it does not, the browser cannot read the bytes it needs to hash, and the resource is blocked with an opaque-response error rather than an integrity mismatch. Getting the header right at the CDN layer is the fix; the hash generation side is covered under Subresource Integrity validation.
Both are solved by attaching immutable-cors to the behaviours that serve fonts and any script loaded with crossorigin. Avoid Access-Control-Allow-Origin: * on a distribution that also serves credentialed responses; name your origins explicitly as in the CLI snippet above.
Verify Against the Distribution
One command, run against the distribution domain rather than the bucket, tells you whether the policy is live:
curl -sI "https://d1234abcd.cloudfront.net/assets/app.a1b2c3d4.js" \
| grep -iE "^(cache-control|access-control-allow-origin|timing-allow-origin|age|x-cache|etag)"
A correctly configured hashed asset returns:
cache-control: public, max-age=31536000, immutable
timing-allow-origin: *
age: 41207
x-cache: Hit from cloudfront
etag: "3f9c1b7e2a5d8c04f6b1e9a72d40c8b5"
age counting up across requests while cache-control stays at a year confirms the edge is retaining the object and the policy is stamping the header on cached responses, not just on origin fetches. If cache-control shows a short max-age instead, the object’s own S3 metadata is winning — set Override: true and try again. If the header is missing entirely, the request matched a different behaviour than you think; check the path pattern order.
Contrast the HTML entry point in the same session:
curl -sI "https://d1234abcd.cloudfront.net/index.html" \
| grep -iE "^(cache-control|age|x-cache)"
That should return max-age=60, must-revalidate with a small age. Those two commands together are the whole contract: hashed paths get a year and never need purging, the entry document revalidates and is the only thing you ever invalidate.
When to Reconsider
Per-object headers that genuinely vary. If different objects under the same prefix legitimately need different freshness — a mixed bucket of generated reports and static bundles — the header belongs in S3 metadata with Override: false, or the prefixes should be split so each gets its own behaviour.
You are already writing correct metadata at upload. A deploy that sets --cache-control "public, max-age=31536000, immutable" on the hashed prefix and --cache-control "public, max-age=60" on HTML is not wrong, and adds no CloudFront resources to manage. The response-headers policy wins when uploads come from several tools, when you need to correct a header without touching objects, or when you want a config-reviewable guarantee rather than a script that could regress.
Behaviour count. Each cache behaviour costs a path pattern slot and a rule everyone has to reason about. Three or four is comfortable; twenty means the asset layout should carry the distinction instead. Naming assets so that everything immutable lives under one prefix is the cheaper fix, and the conventions are in the asset naming reference.
Hash length. All of this assumes the filename really is content-addressed. Eight hex characters is the standard default and is safe for typical projects; monorepos emitting thousands of chunks should move to 12–16 to keep the collision margin comfortable, because immutable on a colliding filename is an unrecoverable cache poisoning rather than a stale-asset annoyance.
FAQ
Does changing a response-headers policy require an invalidation?
No. The policy is applied when the response leaves the edge, so the next request served from an already-cached object carries the new header. This is the one header change that propagates without a purge and without an origin write — expect it to take effect within a minute or two as the configuration deploys.
Can I set Cache-Control in both S3 metadata and a response-headers policy?
You can, and with Override: true the policy value is what viewers see. Keeping both in sync is good hygiene anyway: anything fetching directly from the bucket, such as a build-time integrity check, reads the S3 value. Treat the policy as authoritative for viewers and the metadata as a sensible fallback.
Why does immutable matter if max-age is already a year?
max-age alone does not stop a browser revalidating on an explicit reload — a user pressing refresh sends conditional requests for every subresource. immutable tells the browser those requests are pointless, eliminating a burst of 304s on every reload. The full comparison against ETag-based revalidation is in ETag vs immutable Cache-Control.
Related
- CloudFront invalidation — the parent guide: cache behaviours, TTL fields, the CLI and boto3 workflows, and when a purge is actually required
- Cache-Control immutable and TTL tuning — choosing the TTL numbers this policy emits, per asset type
- ETag vs immutable Cache-Control — why revalidation is the wrong model for content-addressed URLs
- Subresource Integrity validation — generating integrity hashes and the CORS requirements that come with them
- CloudFront invalidation cost and wildcard limits — what you stop paying once headers are correct and purges become rare