Including or Ignoring Cookies in the Cache Key
Problem Statement
A site sets a session cookie on every visitor, and the CDN’s default configuration includes the cookie header in the cache key. Hit ratio reads close to zero, the origin absorbs every request, and the CDN is doing nothing except adding a hop. The alternative — ignoring cookies entirely — is worse if any route personalises its response, because the first visitor’s page is then served to everyone.
Prerequisite Concepts
- Cache key normalization and query strings — the key pipeline this decision sits inside.
- Public vs private cache scope — the directive-level control over what a shared cache may store at all.
- Mapping Vary headers to edge routing — cardinality reasoning, applied to headers rather than the URL.
Step-by-Step Resolution
Step 1 — Establish which routes actually vary on a cookie
The answer is usually “fewer than you think”. Test directly by requesting the same URL with two different session values and comparing:
A=$(curl -s -H 'Cookie: sid=aaaaaaaa' https://example.com/products/9472 | sha256sum)
B=$(curl -s -H 'Cookie: sid=bbbbbbbb' https://example.com/products/9472 | sha256sum)
[ "$A" = "$B" ] && echo "identical — cookie can leave the key" \
|| echo "differs — this route is personalised"
Run it across a representative set of routes. Most product, category and content pages are identical; a header fragment showing a user’s name usually is not.
Step 2 — Split personalised routes from shared ones
The durable fix is architectural rather than configuration. A page consists of a shared shell — layout, product data, copy — and a small personalised region. Serving them separately makes the large part cacheable for everyone:
GET /products/9472 -> public, s-maxage=600 (identical for all)
GET /api/me/header-fragment -> private, no-cache (per user, small)
Step 3 — Exclude cookies from the key on shared routes
sub vcl_recv {
# Shared routes do not vary on identity; keep the cookie out of the key
if (req.url ~ "^/(products|categories|guides)/") {
unset req.http.Cookie;
}
}
Unsetting the header rather than merely excluding it from the key also prevents the origin from personalising a response that is about to be stored publicly — a useful belt-and-braces measure, since the dangerous failure is an origin that varies on something the edge has decided to ignore.
Step 4 — Bucket where a dimension is genuinely needed
Some distinctions are real but low-cardinality: logged-in versus anonymous, a locale, an experiment variant. Derive a bucket at the edge and key on that instead of the raw cookie:
sub vcl_recv {
# Reduce identity to a two-state bucket before it reaches the key
if (req.http.Cookie ~ "sid=") {
set req.http.X-Auth-Bucket = "member";
} else {
set req.http.X-Auth-Bucket = "anon";
}
unset req.http.Cookie;
}
sub vcl_hash {
hash_data(req.http.X-Auth-Bucket);
}
Two entries per URL instead of forty thousand, with the distinction that actually affects the response preserved.
Step 5 — Prove nothing personalised is stored
This is the verification that matters, because the failure mode is a data exposure rather than a slow page:
# Two distinct sessions, one shared route — the bodies must match
curl -s -H 'Cookie: sid=user-one' https://example.com/products/9472 > one.html
curl -s -H 'Cookie: sid=user-two' https://example.com/products/9472 > two.html
diff one.html two.html && echo "safe to share one entry"
Any difference means the route personalises and must not have its cookie excluded. Repeat the check whenever a template gains a new region, because personalisation tends to be added to shared templates long after the caching decision was made.
Expected Output / Verification
A correctly configured shared route reports a high Age and an identical body across sessions, while personalised routes are marked private and never appear in shared storage:
GET /products/9472
CF-Cache-Status: HIT
Age: 431
Cache-Control: public, s-maxage=600
GET /api/me/header-fragment
CF-Cache-Status: BYPASS
Cache-Control: private, no-cache
Edge Cases
- A route that becomes personalised later. The caching decision was correct when made and is not revisited. A template change that adds a user’s name to a cached shell is a data-exposure incident with no code change at the CDN.
- Analytics cookies triggering the key. A cookie set by a third-party script has nothing to do with the response but still fragments the key if the whole header is included. Bucket or unset rather than including wholesale.
Vary: Cookiefrom the origin. Even with the cookie excluded from the key, an origin advertisingVary: Cookieforces most caches to treat every distinct cookie value as a separate variant. Remove it on shared routes.- Consent banners changing markup. A consent state that alters the page is a legitimate bucket, but only if it has a small number of states; a free-form preferences blob does not.
- Edge-injected personalisation. Injecting a name into a cached shell at the edge keeps the shell shared, but the injection must happen after the cache lookup or the personalised version is what gets stored.
- Different cookie rules at the shield. As with every key rule, a mismatch between tiers means the shield cannot serve the edge’s misses.
Frequently Asked Questions
Why is a session cookie so damaging in the cache key?
Because its cardinality equals the number of visitors. Every user gets their own entry, every entry is cold on first request, and the shared cache degrades into a very expensive private one with a hit ratio near zero.
Is it safe to simply ignore all cookies?
Only on routes whose response does not depend on them. If the origin personalises based on a cookie and the edge ignores it, the first visitor’s personalised response is stored and served to everyone — a data-exposure incident, not a performance bug.
What is a bucketed cookie?
A derived value with few possible states — a device class, a locale, an A/B variant — computed at the edge from the raw cookie. Varying on the bucket keeps the entry count small while preserving the distinction that matters.
How should personalised content be delivered instead?
Serve a cacheable shared shell and fetch the personalised fragment separately, or inject it at the edge. The shell is then cacheable for everyone and only the small personalised part is uncacheable.
Related
- Public vs private cache scope covers the directive that keeps personalised responses out of shared storage entirely.
- Mapping Vary headers to edge routing explains the header-axis equivalent of this decision.
- When to use no-store for sensitive API responses covers the responses that must not be stored at any tier regardless of key.