Frameworks (Django, Laravel, Rails, …) + cache-turbo

August 24, 2026 · View on GitHub

Last researched: 2026-07-26

There is no django preset, no laravel preset, and there never will be. Not an oversight — a framework is not a cacheable thing. This page explains why, and then hands you the thing a preset would have been: a procedure for deriving the correct rule for your app, and a vhost that is safe before you derive anything.

If you are here because you run a specific application that happens to be built on a framework — Discourse is Rails, Magento is Laminas/Zend — use that app's preset, not this page. See README.md.

Why there is no framework preset

A preset is three literals: Cookie-header substrings (matched across cookie names and values, not as cookie names), URI prefixes, query-arg keys. An application can supply all three because it ships a fixed cookie name and a fixed URL layout. A framework supplies none of them.

An app (WordPress)A framework (Django/Laravel)
Cookie namewordpress_logged_in_<hash> — fixed by the appapp- or env-derived; see below
Admin URI/wp-admin/ — fixed by the app/admin/ is one line of urls.py away from /manage/; Laravel has no default at all
Anonymous surfaceblog posts — knowableundefined. A Django install is a blog or a bank

The rejection test from README.md is "what fraction of this app's requests are pages a logged-out stranger can see, that look the same for every logged-out stranger?" For an app that question has an answer. For a framework it is not a question about the framework — it is a question about the code someone wrote on top of it. We cannot answer it from here, and a preset that pretends to would be an attractive nuisance: it would imply we had checked, when only you can check.

And the one rule a framework preset could plausibly ship — "bypass on the session cookie" — is broken on both of the big two, for two different reasons:

  • Laravel's session cookie is not called laravel_session on your site. It is derived from APP_NAME in the shipped config/session.php, and the exact derivation changed between skeleton versions. The operative default is the one in the laravel/laravel application skeleton an operator actually deploys — not the laravel/framework library stub, which still uses Str::snake(...) . '_session' on 13.x; that mismatch is why version tables disagree. In the skeleton, Laravel 11 shipped Str::slug(env('APP_NAME', 'laravel'), '_') . '_session' — underscores — so an APP_NAME=Acme Shop install emitted acme_shop_session. Laravel 12 and 13 use the new default (dropping the '_' separator so Str::slug falls back to its hyphen, and suffixing -sessionStr::slug(env('APP_NAME','laravel')) . '-session'), so a current install emits the hyphenated acme-shop-session. (12.21.0 briefly swapped Str::slug for Str::snake, which broke APP_NAMEs containing a period — laravel/framework#56449 — and was reverted; it never changed the underscore/hyphen boundary.) In 11.x, laravel_session was the fallback when APP_NAME was unset; current skeletons produce laravel-session instead. A shipped literal for either spelling would match almost no production Laravel site — and the modern hyphenated name additionally cannot be read with $cookie_ at all (see the hyphen gotcha). Same failure class as Joomla and Drupal: a per-install (now also per-version) name is not a shippable literal.
  • …and even the right name would be useless, because Laravel cookies every guest. StartSession::addCookieToResponse() gates only on "is a session driver configured" — there is no empty-or-modified check. Every anonymous visitor to any web-middleware route gets a session cookie on the first response. A bypass on it bypasses 100% of traffic. This is the _sid trap from phpBB and the MoodleSession trap, wearing a Laravel hat.

Django is the interesting one, because a Django rule can work — conditionally, which is precisely why it cannot be a preset. See below.

CSRF cookies are never a bypass signal. Django's csrftoken and Laravel's XSRF-TOKEN are handed to anonymous visitors by design — any page rendering a form (a search box in the header is enough) sets one. They are the single most tempting wrong answer here: stable name, framework-level, and utterly guest-issued. Bypassing on csrftoken zeroes your hit rate and finds no logged-in user. This is the same rule the app presets encode as "a cookie the app issues to guests can never be a bypass."

Start here: the safe default

Before you know anything about your app, this is correct and costs you nothing:

cache_turbo               ct;
cache_turbo_cache_control honor;      # <- the whole safety story

honor refuses to store any response carrying private, no-cache or no-store. You do not need a preset to switch it on — cache_turbo_backend merely implies it, and with no applicable preset you set it yourself. That single directive is what makes several frameworks safe with no cookie rule at all, exactly as it does for Drupal:

  • ASP.NET Core writes Cache-Control: no-cache,no-store on the very response that sets the session cookie (SessionMiddleware.SetCookie()), and on cookie-auth responses. Strongest origin backstop of any framework here.
  • Symfony marks the response private whenever the session is started.
  • Next.js (App Router) sends private, no-cache, no-store, max-age=0, must-revalidate on any dynamically-rendered route — and reading cookies() is what makes a route dynamic. Static/ISR routes stay cacheable, which is the correct split.

Do not assume it for Django, Laravel, Rails, Flask or Express. None of them reliably marks an authenticated response private out of the box. On those, honor is a backstop that may not fire, so the cookie rule you derive below is the primary defence, not a belt-and-braces extra.

Vary: Cookie is not Cache-Control: private. Django and Flask both add Vary: Cookie when the session is merely accessed. That is a keying instruction, not a do-not-store instruction — it does not stop a cache storing an authenticated page. Seeing Vary: Cookie in a response is not evidence you are safe.

Deriving your own rule (the 3 curls)

The question a preset would answer for you is "which cookie means logged in?" Answer it empirically, against your app, in three commands. Do this before you write a single cache_turbo_bypass.

1. What does a logged-OUT stranger get? Anything here is disqualified as a bypass signal — by definition, it does not mean "logged in".

curl -s -o /dev/null -D- https://example.com/ | grep -i set-cookie
# Django:  Set-Cookie: csrftoken=...            <- guest cookie, NOT a bypass
# Laravel: Set-Cookie: acme_session=...         <- guest cookie, NOT a bypass
#          Set-Cookie: XSRF-TOKEN=...           <- guest cookie, NOT a bypass
# ideal:   (no output)                          <- every cookie below is meaningful

2. What does a logged-IN user get that the stranger did not? That difference — and only that difference — is your bypass signal.

curl -s -c jar.txt -b jar.txt -X POST https://example.com/login \
     -d 'username=you&password=...' -o /dev/null
grep -vE '^#|^$' jar.txt | awk '{print \$6}'
# Django on a brochure site:  csrftoken  sessionid   <- sessionid is new => that's it
# Laravel:                    acme_session XSRF-TOKEN <- SAME NAMES as step 1 => useless

If step 2's cookie names are identical to step 1's, you have no presence-based signal at all and must key on a value (see phpbb.md, which has the same problem and shows the map that solves it) — or bypass on a URI prefix instead.

3. Does the origin protect you anyway? Ask the logged-in session for a page a guest can also see.

curl -s -o /dev/null -D- -b jar.txt https://example.com/ | grep -i cache-control
# Cache-Control: private, ...   <- honor mode refuses to store it. You have a net.
# (nothing)                     <- YOU HAVE NO NET. The cookie rule is load-bearing.

Then wire the answer up. If step 2 gave you a distinct name, e.g. Django's sessionid:

cache_turbo_bypass   $cookie_sessionid;
cache_turbo_no_store $cookie_sessionid;

If the name is per-install or hyphenated, use a map on $http_cookie — and read the hyphen gotcha first, it will bite you.

Per-framework field notes

Verified against framework source, not from memory. The "guest cookie?" column is the one that decides everything.

FrameworkSession cookieStable name?Set for a guest?Origin sends private?
DjangosessionidConditional: only if something writes the session — see belowNo (only Vary: Cookie)
Laravel<APP_NAME>_session (11.x) / <APP_NAME>-session (12/13.x)env- & version-derivedYes, alwaysNo
Rails_<appname>_sessionper-appLazy, but flash + CSRF usually trip itNot guaranteed
SymfonyPHPSESSID✅ (but collides — see below)Conditional: lazy in theoryYes, on session start
Flasksession✅ (but dangerously generic)Only if session written (flash() writes)No (only Vary: Cookie)
Expressconnect.sidYes: saveUninitialized: true is the defaultNo
ASP.NET Core.AspNetCore.Identity.Application (auth)No: set only on sign-inYes: no-cache,no-store
Next.jsnone — library-definedlibrary-dependent✅ on dynamic routes

Django — the conditional one. SessionMiddleware sets sessionid only when the session is non-empty AND modified. So a brochure site with no cart, no guest flash messages and CSRF_USE_SESSIONS=False never cookies a guest, and the cache_turbo_bypass/cache_turbo_no_store pair on $cookie_sessionid above is exactly right. But it silently stops being right the moment the app grows:

  • an anonymous cart (request.session['cart'] = …) — cookies every guest; every Django shop does this
  • django.contrib.messages — a large guest flash overflows cookie storage and falls back to the session, cookieing the guest
  • CSRF_USE_SESSIONS = True — now every guest who sees a form gets sessionid
  • SESSION_SAVE_EVERY_REQUEST = True

Each turns your bypass into a 100%-bypass and your hit rate into zero. Nothing errors; the cache just quietly stops working. That fragility is the entire reason this is not a preset — we would be shipping a rule whose correctness depends on code we have never seen. Re-run curl #1 after any significant deploy.

Symfony — do not bypass on PHPSESSID. Same collision that keeps SESS out of the Drupal preset: the registry matches a substring of the whole Cookie header, and PHPSESSID is the stock name for every PHP app on the host. You would bypass co-hosted apps that are not even Symfony. Symfony's saving grace is that it marks the response private on session start, so honor covers you — lean on that, and add a URI bypass for your admin path.

Flask — session is too generic to match safely. A substring rule for session hits laravel_session, _forum_session, xf_session, PHPSESSID… everything. Rename it (SESSION_COOKIE_NAME = 'myapp_sid') and bypass the distinctive name.

Rails / Next.js — the name is not knowable from here. Rails derives it from the app name; Next.js has no session at all. The authentication library supplies the cookie: Auth.js v5 defaults to authjs.session-token and prefixes it as __Secure-authjs.session-token over HTTPS; NextAuth.js v4 used the corresponding next-auth.session-token names. The name can also be configured, so derive it with the curls instead of copying a version-specific literal.

Vhost

Django + gunicorn, with the derived rule. The shape is the same for any framework — swap the cookie name for the one your curl #2 produced.

load_module modules/ngx_http_cache_turbo_module.so;

http {
    cache_turbo_zone name=ct 256m;

    server {
        listen 443 ssl http2;
        server_name example.com;

        location / {
            cache_turbo               ct;

            # No preset applies. Turn the origin backstop on by hand --
            # cache_turbo_backend would have implied this, but there is no
            # backend to name. On Django this is a WEAK net (Django does not
            # send `private`), so the bypass below is the real defence.
            cache_turbo_cache_control honor;

            # THE derived rule. Verified with curl: a logged-out GET does not
            # set `sessionid`. Re-verify after adding a cart or guest flash
            # messages -- either one cookies guests and silently zeroes the
            # hit rate. See docs/frameworks.md.
            cache_turbo_bypass        $cookie_sessionid;
            cache_turbo_no_store      $cookie_sessionid;

            # NOT $cookie_csrftoken -- Django hands that to anonymous visitors,
            # so bypassing on it would bypass everything.

            cache_turbo_valid         60s;
            cache_turbo_valid         404 410 1m;
            cache_turbo_preset        balanced;

            proxy_pass http://127.0.0.1:8000;
            proxy_set_header Host              $host;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        # App-defined dynamic surfaces. A preset would have shipped these --
        # since none can, YOU must. Every private route needs a line here.
        location ^~ /admin/    { cache_turbo off; proxy_pass http://127.0.0.1:8000; }
        location ^~ /accounts/ { cache_turbo off; proxy_pass http://127.0.0.1:8000; }
        location ^~ /api/      { cache_turbo off; proxy_pass http://127.0.0.1:8000; }

        # Hashed static assets (collectstatic / ManifestStaticFilesStorage).
        location ^~ /static/ {
            cache_turbo off;
            alias /var/www/app/static/;
            expires 30d;
            access_log off;
        }

        # User uploads are frequently permission-checked. Do not cache blindly.
        location ^~ /media/ { cache_turbo off; alias /var/www/app/media/; }

        location = /_cache {
            cache_turbo_admin on;
            allow 127.0.0.1;
            deny  all;
        }
    }
}

Checking it works

add_header X-Cache-Turbo $cache_turbo_status always;

Verify with GET, not curl -sI. A HEAD response is served but never stored, so curl -sI shows MISS forever no matter how correct your config is — you will conclude the cache is broken when it is fine. Use curl -s -D- (GET, headers shown) throughout. Better still, watch the body: point at a URL whose content changes per request, and an unchanged body on the second GET is proof it came from cache.

# anonymous page: MISS then HIT  (GET -- -I would never HIT)
curl -s -D- -o /dev/null https://example.com/ | grep -i x-cache-turbo
curl -s -D- -o /dev/null https://example.com/ | grep -i x-cache-turbo   # HIT

# admin: BYPASS
curl -s -D- -o /dev/null https://example.com/admin/ | grep -i x-cache-turbo

# THE ONE THAT MATTERS: logged in, on a URL a guest can also fetch.
# Must be BYPASS. If it is ever HIT you are serving one user's page to another.
curl -s -D- -o /dev/null -b jar.txt https://example.com/ | grep -i x-cache-turbo

# THE OTHER ONE THAT MATTERS, and the one people skip: has the bypass
# accidentally become universal? A guest must NOT be bypassed.
curl -s -D- -o /dev/null https://example.com/ | grep -i x-cache-turbo
# BYPASS here => your "logged-in" cookie is being handed to guests.
# Hit rate is zero and the cache is doing nothing. Re-run the 3 curls.

The strongest form of the logged-in check does not trust the status header at all — it compares bodies, because a leak is defined by bytes, not by a label:

curl -s https://example.com/ > guest.html                 # warm the cache
curl -s -b jar.txt https://example.com/ > user.html       # same URL, logged in
grep -qi 'log out\|your account' user.html \
  && ! diff -q guest.html user.html >/dev/null \
  && echo "OK: user got their own page, not the cached guest one"

If user.html is byte-identical to guest.html, the logged-in user was served the cached anonymous page — the bypass is not firing. That is the failure this whole page exists to prevent, and it is invisible if you only read x-cache-turbo.

That last check is the framework-specific one. On an app preset a guest bypass is near-impossible; here it is the default failure mode, because the cookie you bypassed on may be issued to everyone. Put it in your monitoring — a cache that silently stopped caching looks exactly like a cache that is working, until the origin falls over.

Two engines the presets have that you can now use directly

A preset is three literal lists (bypass cookies, bypass URIs, dynamic query args) plus, for a few, two engines that plain nginx config could not express. Since v15 both engines are available as directives, so a hand-configured site gets exactly what a preset gets — no preset required.

cache_turbo_bypass_uri — segment-boundary URI bypass

cache_turbo_bypass_uri  /admin  /account/;

Skips the cache entirely (origin, never captured) for any request whose URI matches a listed prefix on a path-segment boundary — the byte after the prefix must be /, ., or end-of-string. So /admin bypasses /admin, /admin/users, and /admin.php, but not /administrator (the letters continue past the needle — a different resource). A needle ending in / (/account/) carries its own boundary and bypasses the whole subtree.

This is the one thing a plain nginx location prefix cannot do: location prefixes anchor at position 0 and have no boundary semantics, so a location that bypasses /admin also swallows /administrator, and mounting the app in a subdirectory silently mis-matches. cache_turbo_bypass_uri gives the explicit boundary behaviour, but its literals are not rebased: supply the app's deployed path (for example, /shop/admin for an app mounted at /shop/).

Prefixes must start with /. It works with or without a cache_turbo_backend preset — in pure manual mode it is your whole URI-bypass surface.

cache_turbo_key_cookie  X-Segment;

Value-keys the named cookie: visitors carrying different values get different cache entries, visitors sharing a value share one entry, and a visitor with no cookie gets the plain anonymous entry. This is the magento/shopware6 tier-3 engine as a directive.

Use it only for a cookie that is a segment fingerprint — a marker of which shared variant of a page to serve (customer group, currency, store view, A/B bucket) that many visitors legitimately share — never for an identity cookie (a session id, a login token). Keying on an identity cookie gives one entry per visitor (hit rate ≈ 0) and puts authenticated HTML in a shared cache. For an identity cookie you want cache_turbo_bypass plus cache_turbo_no_store on the same variable, not this — the bypass skips only the lookup, so on its own the authenticated response is still stored under the shared key.

Why a directive and not just cache_turbo_key $cookie_x:

  • The name is matched EXACTLY and every Cookie: header is scanned in header order. If the same name occurs more than once, the first exact match wins and later duplicates are ignored; header order therefore selects the bucket for duplicate names. A suffix match or a later header cannot override an earlier exact match.
  • The value is folded with an unforgeable length-prefixed framing, so no cookie value can splice itself into a neighbouring key field — a plain delimiter in cache_turbo_key is forgeable (nginx permits the 0x1f separator byte inside header values).
  • It also reads hyphenated cookie names (X-Magento-Vary, sw-cache-hash) that $cookie_<name> silently cannot — see the $cookie_ trap below.

The Set-Cookie store floor covers the transition race for free: a request with no key cookie keys to the anonymous entry, and if the response establishes the segment (Set-Cookie: X-Segment=...) that response is never stored, so it can never poison the anonymous entry.

Keep the name list short — the cost is multiplicative, and it is paid on every keyed request including a cache HIT. Each configured name drives its own scan of every Cookie: header on the request, so the work is names x cookie-headers, not names. Ten names against a browser sending ten cookie headers is a hundred header scans on the hit path, where the rest of the lookup is a hash and a tree descent.

There is deliberately no cap on the list. Both factors are already bounded by someone other than an attacker: the name count is yours, chosen once in the config, and the header count is bounded by nginx's large_client_header_buffers before this module ever runs. A limit here would only turn a working config into a startup failure, so this is a budget you keep, not a limit enforced on you — unlike cache_turbo_normalize_max_args, which bounds a count the client picks and therefore has to be a hard cap.

Two ways the list gets longer than the config appears to say, both worth a look before you assume it is short:

  • The directive appends. Repeating it adds names, it does not replace them: cache_turbo_key_cookie a b; followed by cache_turbo_key_cookie c; keys on all three.
  • It inherits into nested locations, and a cache_turbo_backend preset contributes its own key cookies on top of yours -- so the effective list at a given location can be longer than any single line you wrote.

Every name you add also multiplies cardinality: entries are one per distinct combination of values. That usually bites before the scan cost does.

Origin failure: stale-if-error

By default this module can serve a stale cached copy when the origin returns 5xx; nginx turns a refused connection into a 502 and a hung one into a 504, so a dead origin is covered. If the response supplies no stale-if-error, cache_turbo_keep_stale provides the fallback window — it defaults to 24h, and cache_turbo_keep_stale off removes that fallback. An honored response stale-if-error takes precedence, while an honored must-revalidate forbids stale serving. cache_turbo_use_stale selects which statuses count as "down" (default: every 5xx); listing any tokens replaces the default rather than extending it. Nothing was ever cached for a URL ⇒ nothing to serve; error_page 502 503 504 /maintenance.html is the final fallback.

cache_turbo_keep_stale    2h;
cache_turbo_valid         60s;

The copy stays fresh for 60s; if the origin starts failing after that, the expired copy keeps being served for up to 2h (cache_turbo_keep_stale). Past that window, or with nothing cached at all, error_page is the fallback. See the README sections on which failures count as "the origin is down" and what outage handling cannot do.

Gotchas

  • $cookie_<name> does not translate - to _. Unlike headers. So $cookie_XSRF_TOKEN never matches XSRF-TOKEN — no error, just a permanently empty variable, and a bypass that never fires. Any hyphenated cookie (XSRF-TOKEN, authjs.session-token) can only be read with a map on $http_cookie. Dots are just as bad. This has bitten us before; see magento.md. If you need to value-KEY such a cookie (a segment fingerprint, not an identity), cache_turbo_key_cookie reads hyphenated/dotted names natively and skips the map entirely.
  • HEAD responses are never stored, so curl -sI can never show a HIT. It will report MISS on a perfectly working cache, forever. Debug with a GET while discarding its body: curl -s -o /dev/null -D- URL. The 3-curl procedure above uses that form as well, so application-specific HEAD handling cannot skew its cookie or cache-control observations.
  • A regex map needs PCRE. On an nginx build configured --without-pcre, a ~pattern map key falls through as a literal string: nginx -t succeeds but the intended regex never matches. --without-http_rewrite_module is a different option and does not by itself prove that PCRE is absent. Check nginx -V for --without-pcre, or exercise the map in a config test.
  • Do not bypass on the CSRF cookie. Bears repeating: csrftoken / XSRF-TOKEN are guest cookies. This is the most common way to get a 0% hit rate and think you have configured security.
  • Do not put the session cookie in cache_turbo_key. One entry per visitor (hit rate ≈ 0) and authenticated HTML in the cache. Neither safe nor fast. See README.md — the rule the presets encode.
  • cache_turbo_backend generic/auto are hard config errors, not a fallback for "framework, no preset". There is no union preset. The correct spelling for "cache this, no preset applies" is simply to omit cache_turbo_backend and set cache_turbo_cache_control honor; yourself.
  • A framework in a subdirectory breaks every byte-0 URI rule — including the presets' own. Anchoring at position 0 is not just an nginx location quirk; the cache_turbo_backend presets ship their URI rules as root-relative literals (/wp-admin/, /administrator/) and match them the same way. If the app is mounted at /shop/, /admin/ is /shop/admin/, so your location ^~ /admin/ never fires and the preset's /admin/ rule never matches either — the mounted app gets zero URI-rule coverage, admin surface included. Two distinct fixes, for two distinct rule sets:
    • cache_turbo_backend_prefix declares the mount so the preset rules are rebased onto it (cache_turbo_backend_prefix /shop/;/shop/wp-admin/ is tested as /wp-admin/). This is the one that restores preset coverage.
    • cache_turbo_bypass_uri is for your own rules: give it the real mounted paths and it matches on a segment boundary, so /administrator is left cacheable where /admin would have swallowed it. Its prefixes are literals you author, so they are not rebased.
  • Re-derive after deploys. The Django conditions above are code-dependent, not config-dependent — a developer adding an anonymous cart changes your cache's correctness without touching nginx. Nobody will tell you.
  • Set-Cookie responses are never stored and Authorization requests are never cached, regardless of any of this. Those floors hold even if every rule above is wrong.

Runtime settings / gotchas

The 3 curls tell you which cookie to key on. This section is the layer under that: framework-specific origin behaviour that suppresses caching, competes with the module, or needs tuning on the MISS path. Only the gotchas that are particular to each framework are listed — the universal floors (Set-Cookie never stored, Authorization never cached) hold everywhere.

Laravel (PHP). Both cookies it sets are guest cookies: the session cookie (StartSession::addCookieToResponse() gates only on a session driver being configured, so every web-middleware response cookies the visitor) and XSRF-TOKEN (written whenever the encrypt-cookies middleware runs — i.e. on GETs that render a form). There is no config knob to make the web-group session lazy, so you cannot key on presence — lean on cache_turbo_cache_control honor; (weak here: Laravel does not send private) plus a URI bypass of the authenticated area. Never fold either cookie into the key; XSRF-TOKEN is hyphenated, so $cookie_XSRF_TOKEN reads empty and cache_turbo_key_cookie is the only thing that could read it — which you must not do for a guest token anyway. Origin MISS latency is opcache-bound: enable OPcache with opcache.validate_timestamps=0 in production (the module serves HITs itself, so opcache only pays on the MISS).

Symfony (PHP). PHPSESSID (or the configured framework.session.name) is the stock PHP name shared by every co-hosted PHP app, so a substring bypass on it hits neighbours that are not even Symfony — do not bypass on it. Symfony's saving grace is AbstractSessionListener: it stamps the response private the moment the session is started, so cache_turbo_cache_control honor; genuinely covers authenticated pages (add a URI bypass for the admin path as belt-and-braces). Two traps: (1) an app that opts a session-bearing response back into caching via AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER drops that private — audit for it. (2) Symfony ships its own PHP reverse proxy (framework.http_cache: true, visible via the X-Symfony-Cache header); running it behind cache-turbo gives you two stacked gateway caches with two TTLs. Pick one edge — usually disable framework.http_cache and let cache-turbo be the cache. Opcache tuning as for Laravel.

Rails (Ruby). The cookie is _<app>_session, per-app (from config/session_store.rb), and the session is lazy — but the CSRF authenticity_token lives in the session by default, so the first form render starts it and cookies the guest. Rails 7.1+ can instead use protect_from_forgery store: :cookie to put the CSRF token in its own encrypted cookie. The old config.action_controller.urlsafe_csrf_tokens setting controlled token encoding; it did not select storage. A cookie storage strategy stops this session write but adds a distinct guest cookie — re-run curl #1 and do not bypass on it. Rails does not reliably send private, so the derived cookie rule is load-bearing. Rack::Cache stopped being a default dependency in Rails 4; if the app re-enables it (the rack-cache gem + config.action_dispatch.rack_cache = true) you get a second HTTP cache — prefer cache-turbo at the edge and leave Rack::Cache off. MISS throughput scales with Puma workers (WEB_CONCURRENCY), not with the cache.

Django (Python). csrftoken is a guest cookie by design (any form-rendering GET sets it) — never a bypass signal, never in the key. sessionid is set only on a non-empty modified session, which is exactly the conditional the field notes above warn about. Watch two settings that turn sessionid into a guest cookie: CSRF_USE_SESSIONS = True (moves the CSRF token into the session, so every form-GET now writes it) and SESSION_SAVE_EVERY_REQUEST = True. Session middleware also emits Vary: Cookie whenever the session is accessed — that is a keying instruction, not private, so honor will not fire on it and it is no evidence of safety. MISS throughput scales with gunicorn workers, not the cache.

Express (Node). connect.sid is the express-session default, and saveUninitialized defaults to true — which saves and cookies every anonymous visitor, zeroing your hit rate. The single highest-value origin change is saveUninitialized: false, so a guest who never writes the session is never cookied and the response stays cacheable; presence of connect.sid then genuinely means "has state". Express sends no private of its own, so the cookie/URI bypass is the only defence. A CSRF layer (csurf, csrf-csrf) sets its own cookie on GET — keep it out of the key. MISS throughput scales with the Node cluster / PM2 worker count.

See also