CloudFront Cache Policy for Vary: Accept negotiation

Your origin sets Vary: Accept, your <picture> element is correct, and yet every visitor on a warm CloudFront edge receives the same image format — the one the first visitor happened to request. This is not a bug in your origin; it is CloudFront working as designed. CloudFront does not honour the origin’s Vary: Accept when building its cache key, so you must instead add Accept to the cache key yourself through a Cache Policy. This guide sits inside AWS CloudFront Cache Behaviors for Media, within the broader CDN & Edge Media Delivery section, and walks the exact policy, the normalization step that keeps it from wrecking your hit ratio, and the curl checks that prove it works.

Why CloudFront ignores origin Vary: Accept

RFC 7234 caches use the Vary response header to remember which request headers a stored response depends on. CloudFront is deliberately not a fully Vary-driven cache. Its cache key is constructed from an explicit, operator-defined set of dimensions: the URL path, plus exactly the headers, cookies, and query strings named in the attached Cache Policy. Vary from the origin is passed through to the browser (so the browser’s own cache behaves correctly), but it plays no part in CloudFront’s edge cache key.

The consequence: if your Cache Policy does not name Accept, then two requests to /images/hero that differ only in Accept collapse to one cache key. The first request populates the object — say, AVIF — and CloudFront serves that AVIF to a Safari 14 client that asked for WebP. The fix is to make Accept a cache-key dimension so the two requests resolve to two different objects.

This mirrors the origin-side reasoning in the cache-control headers guide: Vary: Accept is the correct signal, but each cache layer must be told to act on it in its own dialect. Nginx and Cloudflare read Vary directly; CloudFront needs the header in its Cache Policy.

The diagram below contrasts keying on the raw Accept string (many cold objects) against keying on a normalized token (three warm objects):

Raw vs normalized Accept as a CloudFront cache key Top row: three browsers send slightly different raw Accept strings that each produce a distinct cache object, fragmenting the cache. Bottom row: a CloudFront Function normalizes those strings to avif, webp, or jpeg, collapsing them to three shared cache objects. Raw Accept keyed → fragmentation Chrome: avif,webp,apng… 200-char string Firefox: avif,webp,*/* different order Safari: webp,*/*;q=0.8 yet another string N cold objects one AVIF stored many times over hit ratio ↓ Normalized token keyed → 3 objects CloudFront Function viewer-request collapses Accept key = image/avif key = image/webp key = image/jpeg 3 warm objects hit ratio ↑

Prerequisite checklist

Exact solution

Step 1 — Create the Accept-keyed Cache Policy

// accept-cache-policy.json
// A Cache Policy that makes Accept part of the cache key so AVIF and WebP
// variants of the same URL are stored as separate edge objects.
{
  "Name": "images-accept-negotiation",
  "Comment": "Key image cache on a normalized Accept token",
  "MinTTL": 1,             // 1s floor; keep low so origin no-cache is honoured
  "DefaultTTL": 86400,     // used only if the origin omits Cache-Control
  "MaxTTL": 31536000,      // 1y ceiling on an over-long origin max-age
  "ParametersInCacheKeyAndForwardedToOrigin": {
    "EnableAcceptEncodingGzip": true,     // normalize + key on gzip
    "EnableAcceptEncodingBrotli": true,   // and brotli (safe for the SVG/JSON alongside)
    "HeadersConfig": {
      "HeaderBehavior": "whitelist",
      "Headers": {
        "Quantity": 1,
        "Items": ["Accept"]               // THE key line: Accept enters the cache key
      }
    },
    "CookiesConfig":      { "CookieBehavior": "none" },      // cookies must never key media
    "QueryStringsConfig": { "QueryStringBehavior": "none" }  // ignore ?utm_/?v cache-busters
  }
}
# Create it and capture the Id — you attach the policy to a behavior by Id.
CACHE_POLICY_ID=$(aws cloudfront create-cache-policy \
  --cache-policy-config file://accept-cache-policy.json \
  --query 'CachePolicy.Id' --output text)
echo "$CACHE_POLICY_ID"

Warning: HeaderBehavior: "allViewer" looks like a convenient superset, but it keys the cache on every viewer header — User-Agent, Accept-Language, and more — shattering your cache into thousands of variants. Whitelist Accept and nothing else for negotiation.

Step 2 — Normalize Accept with a CloudFront Function

Whitelisting the raw Accept header is correct but dangerous. Chrome sends image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8; Firefox sends a slightly different order; Safari sends something else again; and versions drift over time. Each unique string becomes its own cache object. The result is severe cache fragmentation — the same AVIF stored under dozens of near-identical keys, each with an independent, cold miss.

The fix is to collapse the raw header into one of three stable tokens before it reaches the cache key. A viewer-request CloudFront Function is the right tool: it runs in under a millisecond and rewrites the header in place.

// normalize-accept.js — CloudFront Function (viewer-request event)
// Collapses the raw Accept header into exactly one of: image/avif, image/webp, image/jpeg.
// This runs BEFORE the cache-key is computed, so the key has at most 3 Accept variants.
function handler(event) {
  var request = event.request;
  var headers = request.headers;

  // CloudFront Function header values are lowercased keys; read defensively.
  var accept = (headers['accept'] && headers['accept'].value) || '';

  var normalized;
  if (accept.indexOf('image/avif') !== -1) {
    normalized = 'image/avif';        // AVIF-capable: Chrome 85+, Safari 16+, FF 93+
  } else if (accept.indexOf('image/webp') !== -1) {
    normalized = 'image/webp';        // WebP but not AVIF: Safari 14–15, older Chromium
  } else {
    normalized = 'image/jpeg';        // neither: legacy fallback tier
  }

  // Overwrite Accept so the Cache Policy keys on the 3-value normalized token,
  // not the 200-character raw string. The origin still receives a valid Accept.
  request.headers['accept'] = { value: normalized };
  return request;
}

Publish the function and associate it with the /images/* behavior on the viewer-request event:

# Create + publish the function, then associate on viewer-request.
aws cloudfront create-function \
  --name normalize-accept \
  --function-config Comment="Normalize Accept to avif/webp/jpeg",Runtime="cloudfront-js-2.0" \
  --function-code fileb://normalize-accept.js \
  --query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text
# (publish with `aws cloudfront publish-function`, then reference the ARN in the
#  behavior's FunctionAssociations for EventType=viewer-request)

Tradeoff: Normalizing collapses AVIF/WebP negotiation to three tiers, which is exactly what you want for format selection — but if you later need to negotiate on something finer (e.g. AVIF 10-bit vs 8-bit), extend the token set rather than reverting to the raw header. Every distinct token is a distinct cache object, so keep the set as small as the negotiation genuinely requires.

The trigger choice is not a style preference. CloudFront computes the cache key once, immediately after the viewer-request phase returns, and everything downstream — the cache lookup itself, origin-request, the origin, origin-response — sees a key that is already fixed. The timeline below marks that moment:

Where the cache key is frozen in the CloudFront request lifecycle A left-to-right timeline of five phases. The viewer request arrives with a raw Accept header; the viewer-request CloudFront Function rewrites it; the cache key is then computed from the rewritten value; the edge cache lookup follows, serving a hit or continuing on a miss; and only afterwards does the origin-request trigger run, by which point the key can no longer be changed. Viewer request raw Accept 200-char string Cache key computed URL + policy headers uses the new value origin-request runs later too late for the key viewer-request CloudFront Function rewrite Accept HERE Edge cache lookup HIT serves and stops MISS continues right The key is frozen the moment viewer-request returns — nothing downstream can alter it. So normalization must run on viewer-request, never on origin-request.

One practical consequence: because the Function rewrites Accept in place, the origin also receives the normalized value rather than the browser’s original. That is usually desirable — a transcoding origin only needs the token — but if your origin does its own richer negotiation, forward the untouched header alongside it by copying the original into a custom header (X-Original-Accept) before overwriting, and add that header to the Origin Request Policy without adding it to the cache key.

Step 3 — Attach the policy to the image behavior

# Pull the config + ETag, set CachePolicyId on the /images/* behavior, push it back.
aws cloudfront get-distribution-config --id E1EXAMPLE2ID > dist.json
ETAG=$(jq -r '.ETag' dist.json)
# edit dist.json: set the behavior's CachePolicyId to $CACHE_POLICY_ID and add the
# FunctionAssociation for viewer-request; remove any legacy ForwardedValues.
aws cloudfront update-distribution \
  --id E1EXAMPLE2ID --if-match "$ETAG" \
  --distribution-config "$(jq '.DistributionConfig' dist.json)"

Verification with different Accept headers

The proof that Accept is in the cache key is that two different Accept values return two different Content-Type responses and each maintains its own independent cache state.

# 1) AVIF-capable request. Expect: content-type: image/avif
curl -sI -H 'Accept: image/avif,image/webp,image/*,*/*;q=0.8' \
  https://d111111abcdef8.cloudfront.net/images/hero \
  | grep -iE 'content-type|x-cache|age'

# 2) WebP-only request (Safari 14/15). Expect: content-type: image/webp,
#    and a SEPARATE x-cache lifecycle from request 1.
curl -sI -H 'Accept: image/webp,image/*,*/*;q=0.8' \
  https://d111111abcdef8.cloudfront.net/images/hero \
  | grep -iE 'content-type|x-cache|age'

# 3) Legacy request, no modern formats. Expect: content-type: image/jpeg
curl -sI -H 'Accept: */*' \
  https://d111111abcdef8.cloudfront.net/images/hero \
  | grep -iE 'content-type|x-cache'

Run each request twice. The first should show x-cache: Miss from cloudfront; the second, from the same POP, should show Hit from cloudfront with a rising age. If request 1 and request 2 return the same content-type, the Accept header is not keying the cache — recheck Step 1. Deeper hit-ratio forensics live in debugging CloudFront cache misses for images.

Three outcomes are possible, and each points at a different misconfiguration:

Reading the verification results A three-branch decision tree from the curl checks. If all three responses share one content-type, Accept is not in the cache key and the Cache Policy needs fixing. If the content-types differ but x-cache is always a Miss, the raw Accept header is still being keyed and the normalization function is missing or bound to the wrong event. If the types differ and the second run is a Hit, the configuration is correct. Run the three curl checks twice compare content-type and x-cache All three responses share the same content-type Types differ correctly, x-cache is always Miss Types differ and the second run is a Hit Accept is not in the key Cache Policy HeadersConfig does not whitelist Accept redo Step 1 Raw Accept still keyed the normalization Function is missing or on the wrong event — redo Step 2 Working as intended three warm objects per URL, hit ratio intact nothing to do A single Miss on a cold POP is normal; a persistent Miss for the same Accept value is not.

Warning: run the checks against the same POP. curl resolves the distribution’s DNS afresh each invocation and can land on a different edge location between calls, which produces a Miss that looks like a keying failure but is only a cold cache elsewhere. Pin the POP by reading x-amz-cf-pop from the first response and re-running until it matches, or resolve once and reuse the address with --resolve.

Common mistakes

1. Whitelisting the full Accept string → cache fragmentation

Anti-pattern: Adding Accept to the Cache Policy but skipping the normalization function.

Effect: Every browser build sends a slightly different raw Accept string. Each becomes its own cache object, so the same AVIF is stored dozens of times, each entry independently cold. Hit ratio craters and origin load climbs even though “negotiation works” in a single test.

Fix: Normalize Accept to image/avif/image/webp/image/jpeg in a viewer-request CloudFront Function (Step 2) so the key has at most three variants.

2. Expecting origin Vary: Accept to be enough

Anti-pattern: Relying on the origin’s Vary: Accept and never touching the Cache Policy.

Effect: CloudFront passes Vary to the browser but ignores it for its own key. One variant is cached and served to everyone on that POP.

Fix: Add Accept to the Cache Policy HeadersConfig whitelist. Keep sending Vary: Accept from the origin too — it keeps browser and any downstream RFC-compliant caches correct.

3. Using allViewer instead of a whitelist

Anti-pattern: Selecting the managed AllViewer origin-request behavior or allViewer header behavior to “make sure Accept gets through.”

Effect: The cache keys on every viewer header, including User-Agent and Accept-Language, producing near-per-request fragmentation.

Fix: Whitelist exactly Accept in the Cache Policy; if the origin needs more headers, forward them via the Origin Request Policy without keying them.

4. Normalizing in the wrong event

Anti-pattern: Rewriting Accept in a viewer-response or origin-request trigger.

Effect: The cache key is computed at viewer-request time, before origin-request runs, so an origin-request rewrite never affects the key. The raw string still fragments the cache.

Fix: Attach the normalization function on the viewer-request event, which runs before the cache lookup.

5. Keying on Accept for assets that do not negotiate

Anti-pattern: Attaching the Accept-keyed Cache Policy to the whole distribution — or to a behavior that also serves CSS, JavaScript, fonts, and video.

Effect: Those assets have exactly one representation, so every Accept token multiplies their object count for no benefit. A JavaScript bundle now occupies three edge objects instead of one, and each has to be fetched from origin independently. The waste is invisible in a functional test and shows up only as a lower aggregate hit ratio.

Fix: Scope the policy to the image behavior alone (/images/*). Non-negotiated assets should use a policy with HeaderBehavior: none, which gives them a single object per URL.

6. Forgetting that the origin now receives the normalized header

Anti-pattern: Rewriting Accept in place and assuming the origin still sees what the browser sent.

Effect: An origin that inspects Accept for anything beyond the three image tiers — content negotiation for JSON versus HTML on a shared host, or a finer codec decision — receives image/jpeg for every legacy client and silently loses information. Logs that record Accept for analytics also become useless.

Fix: Copy the original value into X-Original-Accept before overwriting, forward that header via the Origin Request Policy, and leave it out of the cache key so it costs nothing in fragmentation.