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.

Two policies, two halves of the request On the request path a cache policy selects cache-key inputs and TTL bounds before the cache lookup. On the response path a response-headers policy adds or overrides headers on the way back to the viewer. Two policies, two halves of the request A cache policy shapes what the edge stores; a response-headers policy shapes what the browser sees REQUEST Viewer request URL + headers + query Cache policy selects cache-key inputs sets Min/Default/Max TTL Cache lookup hit, or fetch origin RESPONSE Origin response S3 object metadata Response headers adds or overrides Cache-Control, CORS Viewer receives the final header set Changing a response-headers policy needs no invalidation of the objects themselves.
The cache policy runs before the lookup; the response-headers policy runs after it, on every response the edge emits.

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) — adds OPTIONS preflight 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?

What the Override flag decides A two-by-two matrix. When the S3 object carries its own Cache-Control, Override false keeps the S3 value and Override true replaces it with the policy value. When the object carries no Cache-Control, the policy value applies either way. What the Override flag decides S3 object metadata Override: false Override: true Object sends Cache-Control max-age=60 from upload S3 header wins: max-age=60 policy value is ignored Policy wins: one year S3 value replaced Object sends nothing no Cache-Control at upload Policy value applied nothing to conflict with Policy value applied same result Override true makes the CDN the single source of truth for the header.
Override only matters when the origin already sent the header; with it on, the policy is authoritative for every object under the behaviour.

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.

One behaviour per path pattern Requests for a hashed bundle, a web font and an HTML document each match a different cache behaviour, which attaches a different response-headers policy and therefore a different header set. One behaviour per path pattern, evaluated in order Request path Cache behaviour Headers on the response /assets/app.a1b2c3d4.js hashed bundle Behaviour /assets/* CachingOptimized policy: immutable-assets max-age=31536000 public, immutable Timing-Allow-Origin /fonts/inter.9e2f1a3b.woff2 cross-origin font Behaviour /fonts/* CachingOptimized policy: immutable-cors max-age=31536000 Allow-Origin for CORS required by crossorigin /index.html entry document Default behaviour no pattern matched policy: html-short-ttl max-age=60 must-revalidate the only purge target
Hashed prefixes get a year and never need purging; the default behaviour catches HTML and keeps it revalidating.

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.