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.
How the key is serialized before it is hashed
It helps to picture the key as a string the edge builds character by character, because every quirk of that construction becomes a quirk of your hit ratio. A typical serialization concatenates the scheme, a separator, the host as the client sent it, the path exactly as received, and then the query string in receipt order. The result is hashed and the digest becomes the storage lookup. Three properties of that process matter operationally.
Order is significant unless the provider explicitly sorts. ?page=2&lang=en and ?lang=en&page=2 produce different strings and therefore different digests, which is why Cloudflare exposes an ignore_query_strings_order toggle and Fastly ships a querystring.sort() function. Frameworks that build URLs from an unordered map will emit both forms from the same codebase, so this is not a theoretical concern.
Case is significant on the path unless you normalize it. /Products/42 and /products/42 are separate objects on a byte-exact key even when the origin treats them identically. Lowercasing the path in the key is safe if — and only if — your origin is genuinely case-insensitive; on an S3-backed origin it is not, and lowercasing will serve the wrong object or a 404.
Percent-encoding is significant too. /search?q=a%20b and /search?q=a+b are the same query to most application frameworks and two different keys to most caches. If your front end and your marketing emails encode spaces differently, you are quietly running two caches for one page.
The practical takeaway is that key hygiene starts before any CDN configuration: emit canonical URLs, normalize them once at the edge, and treat any URL shape your application accepts but does not emit as a fragmentation risk.
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.
Akamai — Property Manager cache key behaviors
Akamai does not expose a single key expression; instead you compose the key from behaviors inside a property rule. The Cache Key Query Parameters behavior offers INCLUDE_ALL, IGNORE_ALL, INCLUDE (an allowlist) and EXCLUDE (a denylist), and a separate Cache ID Modification behavior lets you append a header value or a variable to the identifier. Because behaviors are scoped per rule, you can run an allowlist on /products/ and ignore-all on /assets/ inside one property.
{
"name": "cacheKeyQueryParams",
"options": {
"behavior": "INCLUDE",
"parameters": ["lang", "page"],
"exactMatch": true
}
}
The operational catch is activation latency. A property version must be activated to staging, verified against the staging network, then activated to production, and full propagation commonly takes five to fifteen minutes. That makes Akamai key changes a planned deployment rather than a live tweak, and it makes the staging network genuinely useful — you can confirm the new digest behavior before any production object is orphaned.
Varnish and self-hosted proxies
If you run your own shared cache, the key is whatever vcl_hash says it is, and the default is deliberately minimal: req.url plus the host or server IP. Everything else — normalization, allowlisting, folding a device class in — is code you write. That is more work and considerably more control, and it is the clearest way to understand what the managed providers are doing on your behalf.
sub vcl_recv {
# Canonicalize before anything else looks at the URL
set req.url = std.querysort(req.url);
set req.url = regsuball(req.url, "([?&])(utm_[a-z]+|gclid|fbclid)=[^&]*", "\1");
set req.url = regsub(req.url, "[?&]$", "");
}
sub vcl_hash {
hash_data(req.url);
hash_data(req.http.host);
# one bit of personalization, normalized upstream of the hash
hash_data(req.http.X-Auth-State);
return (lookup);
}
Note the split of responsibilities: vcl_recv rewrites the request so logs, origin fetches and the hash all see the canonical form, while vcl_hash decides identity. Doing normalization inside vcl_hash alone is a common mistake — the hash is then clean but the origin still receives the noisy URL, and any downstream cache keys on the noise you thought you removed.
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 |
| Varnish (self-hosted) | vcl_hash you write yourself |
Whatever vcl_recv normalizes |
You decide; Vary honored by default |
Total control, total responsibility |
Measuring fragmentation before you change anything
Redesigning a key on intuition is how correctness bugs ship. The measurement you want is not the hit ratio itself but the distinct-key count per logical object: how many stored entries exist for what should be one asset. Every CDN can answer this from logs, and the answer usually names the culprit immediately.
# From CDN access logs: which query parameters actually appear in the wild?
awk '{print $7}' access.log \
| grep '?' \
| sed 's/.*?//' \
| tr '&' '\n' \
| cut -d= -f1 \
| sort | uniq -c | sort -rn | head -20
A healthy result is a short list where every name is one your application reads. A list topped by utm_source, gclid, mc_cid and a scattering of one-off parameters you have never heard of is a fragmentation diagnosis without any further work.
The second measurement is per-path, because fragmentation is rarely uniform. Group requests by path and compare distinct URL count to request count; a path with a ratio near 1.0 is caching essentially nothing, while a ratio near zero is behaving.
# Requests per path vs distinct full URLs per path — ratios near 1.0 are fragmented
awk '{split($7,a,"?"); print a[1], $7}' access.log \
| sort \
| awk '{req[$1]++; if (!seen[$2]++) uniq[$1]++}
END {for (p in req) printf "%.2f %7d %s\n", uniq[p]/req[p], req[p], p}' \
| sort -rn | head -15
Do this before and after a key change and you have a defensible before-and-after that does not depend on the hit-ratio graph settling. It also catches the case where a key change improved the ratio for one route while quietly fragmenting another — a real risk when you apply a broad expression to a host rather than a path prefix.
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.
Scenario: the key looks right and the ratio still will not move
Two subtler causes account for most of the cases that survive the table above.
The first is a redirect in front of the cacheable object. If /products/42 issues a 301 to /products/42/ and only the redirect target is cacheable, every request pays an origin round trip for the redirect itself. The hit ratio on the final URL looks excellent while total origin volume never falls. Check whether your measured URL is the one users actually request, and either make the redirect itself cacheable with a long lifetime or eliminate it at the source.
The second is authenticated traffic mixed into the same path. A route that serves anonymous visitors a shared body and logged-in visitors a personalized one will show a hit ratio that tracks your logged-in percentage almost exactly, no matter how clean the key is — the personalized requests are bypassing on Set-Cookie or private, as they should. The fix is architectural rather than configurational: split the personalized fragment out of the cacheable document and fetch it client-side, or fold a normalized auth=0|1 flag into the key so at least the anonymous half caches properly.
# Is the URL you are measuring the one users request, or a redirect target?
curl -sI 'https://app.example.com/products/42' | head -1
# HTTP/2 301 <- every visitor pays an origin round trip before the cacheable URL
# What fraction of requests carry a session cookie at all?
awk '/Cookie:.*session=/ {a++} {t++} END {printf "%.1f%% authenticated\n", 100*a/t}' access.log
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. - Trailing slashes and index paths.
/docs,/docs/and/docs/index.htmlare three keys for one page unless you canonicalize. Pick one form, redirect the others permanently, and make the redirect itself cacheable so the detour is paid once per edge rather than once per visitor. - Purge granularity follows the key. A URL purge invalidates exactly one key, so every variant your key produces must be purged separately. Tag-based invalidation sidesteps this entirely, which is one more argument for the model described in cache purging & invalidation.
- Key changes and stale serving interact. Objects orphaned by a key change cannot be served stale to satisfy the new key, so a
stale-if-errorsafety net that protected you yesterday offers nothing during the warm-up window; see resilient caching for how to size that window. - Geo folding has a correctness edge. Keying on a two-letter country is safe; keying on a region or city multiplies objects fast and, for travellers and VPN users, produces surprising content switches mid-session. Fold to the coarsest bucket your business logic tolerates.
- HTTP method.
GETandHEADnormally share a key whilePOSTis uncacheable, but a route that changes its body based on a header the key ignores can still be poisoned by aHEADprobe. Treat method as part of the eligibility decision, not an afterthought.
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.
Should the cache key include the query-string order or should I sort it?
Sort it. Parameter order carries no meaning to any application framework, but a byte-exact key treats each ordering as a separate object, so an unsorted key silently doubles or triples your object count whenever two code paths build the same URL differently. Enable the provider’s sort option — ignore_query_strings_order on Cloudflare, querystring.sort() on Fastly, std.querysort() on Varnish — and treat sorting as the default rather than an optimization.