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

Step-by-Step Resolution

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.

Entries per URL as cookie handling changesIncluding a per-user cookie makes entry count scale with visitors; bucketing collapses it to the number of meaningful states.Session cookie in key41200entries one per visitorBucketed cookie in key3entries anonymous, member, staffNo cookie in key1entriesthe same page body in every case
Stored entries for one product page over 24 hours

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.

What to do with the cookie on a given routeThe decision follows from whether the response varies on identity and, if it does, whether the distinction can be reduced to a handful of states.Does the response body vary by cookie?noExclude the cookie entirelynoCan the variation be reduced to a few states?yesBucket, then vary on the bucketnoIs the response user-specific and small?yesprivate, no-cache, not sharednoSplit the route and cache the shell
Three outcomes, and only one of them keeps the cookie

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.

Cookie dimensions ranked by cardinalityCardinality is the property that decides affordability; anything approaching one value per visitor destroys a shared cache regardless of how meaningful it is.Distinct valuesVerdictSession idone per visitornever in the keyCart idone per visitornever in the keyAuth bucket2 to 3safeLocale5 to 40safeExperiment variant2 to 6safeConsent state2 to 4safe
What each cookie dimension costs in stored entries

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: Cookie from the origin. Even with the cookie excluded from the key, an origin advertising Vary: Cookie forces 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

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.

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.


Back to Cache Key Normalization and Query Strings