Cache Key & Vary Configuration
The cache key is the deterministic fingerprint a CDN computes for each request to decide whether two requests are “the same object”, and Vary is the HTTP mechanism that splinters that key by request headers — together they govern your hit ratio, your storage footprint, and your exposure to cache poisoning.
Key implementation points:
- The default cache key is built from scheme + host + path + full query string; every byte you leave in it that does not change the response body fragments your cache and lowers the hit ratio.
Varyadds named request headers to the key on a per-object basis;Vary: User-AgentandVary: Cookieare near-pathological because they explode cardinality into millions of variants.- Any input that influences the origin response but is not in the cache key is an unkeyed input — the classic vector for web cache poisoning and cache deception.
- Every major CDN lets you redesign the key: Cloudflare Custom Cache Keys, CloudFront Cache Policies, and Fastly
vcl_hashgive you allowlist/normalize/exclude control over query, headers, and cookies.
What the cache key is and why it decides your hit ratio
When a request reaches an edge node, the CDN serializes a chosen set of request attributes into a string, hashes it, and uses the result to look up an object in local storage. If the lookup matches a fresh object the edge returns a HIT; otherwise it forwards to origin (a MISS), stores the response under that key, and serves it. The freshness side of that decision — how long an object stays usable — is governed by Cache-Control & CDN TTL; the identity side is the cache key discussed here. The two are orthogonal: a perfect Cache-Control: max-age=31536000 buys you nothing if every request computes a unique key.
The default key on virtually every CDN is scheme + host + path + query string. That default is deliberately conservative — it never collapses two URLs that might differ — but it is also wasteful. The full query string means /?a=1&b=2 and /?b=2&a=1 are two objects, and /?utm_source=twitter is a different object from /?utm_source=email even though both return byte-identical HTML. On a marketing site with heavy campaign tagging this single behavior can drop the hit ratio from 95% to under 40%. The fix is query-string normalization: sort parameters, lowercase where safe, and drop parameters that do not influence the response.
Tracking parameters and unkeyed normalization
utm_source, utm_medium, utm_campaign, fbclid, gclid, mc_eid and similar analytics tokens never change the origin response — they exist for client-side JavaScript. Every one of them should be stripped from the cache key (not from the request; analytics still needs them in the URL the browser sees). You have two strategies: an allowlist (“key only on lang and page, ignore everything else”) which is the safest default for content sites, or a denylist (“ignore utm_* and fbclid, key on the rest”) which suits APIs where most parameters are semantically meaningful. Allowlisting is strictly safer because a forgotten new tracking parameter degrades hit ratio rather than silently never appearing — whereas a forgotten meaningful parameter on a denylist serves the wrong cached body. The deeper tuning of these decisions per-route is covered in Customizing Cache Keys to Improve Hit Ratio.
How Vary fragments the cache
Vary is a response header set by the origin that tells caches: “this object’s content depends on the listed request headers, so key on them too.” Vary: Accept-Encoding is correct and necessary — it lets the cache store a gzip variant and a Brotli variant of the same URL separately, which is foundational to Edge Compression & Asset Optimization. The problem is high-cardinality headers.
Vary: User-Agent instructs the cache to store a separate copy of the object for every distinct User-Agent string. There are millions of UA strings in the wild (browser version, OS build, device model, bot signatures), so a Vary: User-Agent response is effectively uncacheable at the shared-cache layer — you will see a near-zero hit ratio and a storage explosion. If you genuinely need mobile-vs-desktop variants, normalize the UA into a low-cardinality custom header (X-Device-Type: mobile|desktop|tablet) at the edge and Vary on that instead.
Vary: Cookie is worse: cookies carry session IDs, CSRF tokens, and consent strings, so almost every authenticated request has a unique Cookie header. Vary: Cookie on a cacheable response means each user gets a private cache entry — at which point you are not caching, you are using shared edge storage as a slow per-user store, and you risk leaking one user’s response to another if normalization is sloppy. The correct pattern is to either mark the response private (origin-side, no shared caching) or strip cookies from the cache key entirely for genuinely public assets.
| Vary header | Cardinality | Safe to use? | Better alternative |
|---|---|---|---|
Accept-Encoding |
Low (2-3) | Yes, required | — keep it |
Accept-Language |
Medium | Sometimes | Normalize to a fixed locale set |
User-Agent |
Very high | No | Normalize to X-Device-Type at edge |
Cookie |
Per-user | No | Mark private or strip from key |
Origin |
Low-medium | Yes (CORS) | Keep, allowlist known origins |
Provider-specific implementation
Cloudflare — Custom Cache Keys and Cache Rules
Cloudflare’s default key ignores most headers and includes the full URL. You override it with Cache Rules (dashboard or Terraform) or, on Enterprise, the legacy cache_key Page Rule fields. The modern approach exposes query_string include/exclude lists, header and cookie allowlists, and a host resolver. Below is a Cache Rule expressed via the API/Terraform cloudflare_ruleset resource:
{
"action": "set_cache_settings",
"expression": "(http.request.uri.path matches \"^/products/\")",
"action_parameters": {
"cache": true,
"cache_key": {
"ignore_query_strings_order": true,
"custom_key": {
"query_string": { "include": ["lang", "page"] },
"header": { "include": ["x-device-type"] },
"cookie": { "include": [] },
"host": { "resolved": true }
}
}
}
}
This keys only on lang and page, adds the normalized x-device-type header, includes no cookies, and sorts the query string. To populate x-device-type you run a Worker that classifies the UA before the cache lookup — see Modifying Request Headers at the CDN Edge Layer. Beware Cloudflare’s cache deception protection: never include user-controlled path suffixes that the origin ignores (e.g. /account/profile/foo.css), or an attacker can trick the edge into caching a private page under a static-looking key.
AWS CloudFront — Cache Policies
CloudFront decoupled the cache key from the origin request in 2020 via managed and custom Cache Policies. The policy explicitly allowlists which query strings, headers, and cookies enter the key; anything not listed is excluded from the key (though it can still be forwarded via a separate Origin Request Policy). Terraform:
resource "aws_cloudfront_cache_policy" "products" {
name = "products-key"
default_ttl = 86400
max_ttl = 31536000
min_ttl = 1
parameters_in_cache_key_and_forwarded_to_origin {
enable_accept_encoding_brotli = true
enable_accept_encoding_gzip = true
query_strings_config {
query_string_behavior = "whitelist"
query_strings { items = ["lang", "page"] }
}
headers_config {
header_behavior = "whitelist"
headers { items = ["X-Device-Type"] }
}
cookies_config { cookie_behavior = "none" }
}
}
Setting enable_accept_encoding_brotli/gzip here is how CloudFront adds the equivalent of Vary: Accept-Encoding to the key without you sending a raw Vary: User-Agent-style header — CloudFront refuses to cache responses that Vary: * or vary on uncontrolled headers. The cookie_behavior = "none" line is the single most impactful setting for hit ratio on most accounts, because the legacy default forwarded all cookies.
Fastly — vcl_hash and custom hash
Fastly gives you raw control through VCL. The default vcl_hash hashes req.url and req.http.host. You override it to normalize and to fold variants into the key directly, which removes the need for a sloppy Vary:
sub vcl_hash {
set req.hash += req.http.host;
set req.hash += std.tolower(req.url.path);
# only key on whitelisted query params, sorted
set req.hash += querystring.sort(querystring.filter_except(req.url.qs,
"lang" + querystring.filtersep() + "page"));
# fold device class into the key instead of Vary: User-Agent
set req.hash += req.http.X-Device-Type;
return(hash);
}
Because Fastly lets you mutate req.http.X-Device-Type in vcl_recv before hashing, you classify the UA once and never expose Vary: User-Agent to downstream caches. Fastly still honors origin Vary, so strip dangerous Vary headers in vcl_fetch (unset beresp.http.Vary; then re-add only Accept-Encoding) if your origin is careless.
Platform comparison
| Provider | Key mechanism | Query/header/cookie control | Vary handling | Failover / notes |
|---|---|---|---|---|
| Cloudflare | Cache Rules + Custom Cache Key | Include/exclude lists; ignore_query_strings_order |
Honors origin Vary; cache deception guard |
Custom keys are Enterprise for some fields; tiered cache shares keys |
| AWS CloudFront | Cache Policy (separate from Origin Request Policy) | Explicit allowlists per category | Built-in encoding flags; rejects Vary: * |
Min/max/default TTL bound to policy; key change = full cache miss |
| Fastly | vcl_hash custom subroutine |
Full VCL: querystring.*, header mutation |
Manual unset beresp.http.Vary in vcl_fetch |
Most flexible; you own correctness of the hash |
| Akamai | Cache Key Query Parameters behavior | Include/exclude/ignore-all in Property Manager | Honor/ignore Vary toggle | Per-rule in property; activation propagation delay |
Step-by-step: designing a high-hit-ratio cache key
- Inventory the route’s inputs. For each cacheable path, list every query parameter, header, and cookie the origin actually reads to build the body. Diff two responses that differ only in one parameter to confirm it is meaningful.
- Choose allowlist over denylist. Default to keying on the smallest set of parameters that produces correct content. On the products route above that was just
langandpage. - Normalize the survivors. Sort query parameters, lowercase the path if your origin is case-insensitive, and collapse equivalent values (
en-US/en-us→en). - Fold device/geo into a low-cardinality header. Classify User-Agent into 2-4 buckets and country into a small region set at the edge, write them to
X-Device-Type/X-Geo, and key on those — never on raw UA or full client IP. - Strip cookies for public objects. Set
cookie_behavior = none(CloudFront), empty cookie include list (Cloudflare), or never addreq.http.Cookieto the hash (Fastly). - Validate before activation. Curl the route with and without each excluded parameter and confirm the same edge object is served:
curl -sI 'https://app.example.com/products/42?lang=en&utm_source=x' | grep -i 'cf-cache-status\|x-cache\|age'
curl -sI 'https://app.example.com/products/42?lang=en&utm_source=y' | grep -i 'cf-cache-status\|x-cache\|age'
# Second request should report HIT and a non-zero Age — utm_source is unkeyed.
- Roll out per route, measure hit ratio. Apply to one path prefix, watch the hit-ratio metric for 24 hours, then widen.
TTL, caching and propagation implications
Changing a cache key invalidates nothing automatically but orphans everything: every object stored under the old key is now unreachable, so the new key effectively starts cold. Plan a key change like a cache flush — expect a temporary spike in origin traffic and MISS rate until the new keyspace warms. This interacts with TTL: short-TTL objects re-warm quickly, but long-max-age static assets under a changed key remain orphaned in storage (consuming quota) until they expire or are purged. Coordinate any key redesign with your purge/deploy pipeline, and pair it with a stale-while-revalidate window so the warm-up MISS storm degrades gracefully rather than hammering origin.
Propagation of the configuration itself also varies: Cloudflare Cache Rules apply in seconds globally, CloudFront cache-policy changes take effect on new requests but the distribution status shows InProgress for several minutes, and Akamai property activations can take 5-15 minutes to reach all networks. Never assume a key change is live everywhere the instant you save it.
Troubleshooting low hit ratio
| Symptom | Likely cause | Fix |
|---|---|---|
| Hit ratio < 50% on static content | Full query string in key; utm_* fragmentation |
Allowlist meaningful params, ignore tracking |
| Near-0% hits, storage growing | Vary: User-Agent or Vary: Cookie from origin |
Strip Vary, fold into low-cardinality header |
| Same URL HITs in one region, MISS in another | Per-PoP cache + cold key after redesign | Enable tiered/shield cache; let keyspace warm |
| Authenticated pages served to wrong user | Cookies in key but normalization leaks; or cached private content |
Mark private, strip cookies, audit deception |
| HIT but stale wrong-language body | A meaningful param (lang) excluded from key |
Add it back to the allowlist |
Diagnostic flow: confirm the response is cacheable at all (status, Cache-Control), then dump the headers the edge actually keyed on. On Cloudflare, cf-cache-status: DYNAMIC means the object was never eligible; MISS/EXPIRED/REVALIDATED/HIT mean it entered the cache path. On CloudFront, X-Cache: Miss from cloudfront plus a changing X-Amz-Cf-Pop distinguishes per-PoP cold misses from genuine key fragmentation.
Edge cases and gotchas
- Cache poisoning via unkeyed input. If the origin reflects an unkeyed header (
X-Forwarded-Host,X-Forwarded-Scheme) into the body or a redirect, an attacker poisons the shared entry for all users. Either key on the header or stop the origin from trusting it. - Cache deception. User-controlled path extensions (
/account/me.css) can trick the edge into treating a private page as a cacheable static asset. Restrict caching by content type, not just extension. Vary: *makes a response uncacheable everywhere — some frameworks emit it accidentally; grep your responses for it.- Case sensitivity. Keys are usually byte-exact:
/Productsand/productsare different objects unless you normalize. - Cookie normalization order. Folding a cookie value into the key requires extracting only the relevant cookie; hashing the whole
Cookieheader reintroduces per-user fragmentation. - Compression variants. Always keep
Accept-Encodingin the key (or use the provider’s encoding flags); dropping it serves Brotli bytes to a gzip-only client.
Frequently Asked Questions
Does excluding a query parameter from the cache key stop it reaching my origin? No. The cache key and the origin-forwarded request are separate on modern CDNs — CloudFront splits them into Cache Policy vs Origin Request Policy, and Cloudflare/Fastly forward the full URL regardless. Excluding a param only stops it from fragmenting your cache.
Is Vary: Cookie ever acceptable?
Almost never on a shared cache. The only safe use is a tiny, low-cardinality cookie set (for example a single A/B bucket value) that you have already normalized to two or three possible values. For session cookies, mark the response private or strip the cookie from the key instead.
Why did my hit ratio collapse to zero right after I changed the cache key? A key change orphans every previously cached object, so the new keyspace starts cold. The ratio should recover within a TTL cycle as objects re-warm. If it stays at zero, you likely introduced a high-cardinality input (raw UA, full cookie, or unsorted query) into the new key.
How do I cache per-device without Vary: User-Agent?
Classify the User-Agent into two to four buckets at the edge, write the result to a custom header such as X-Device-Type, and add that header to the cache key. This caps variants at the number of buckets instead of millions of UA strings.