Where the cookie boundary didn't
Yesterday’s post included this sentence about how Cloudflare was supposed to keep authenticated HTML out of the edge cache:
FeedFilters’ authenticated routes already carry
Set-Cookie— gorilla/csrf sets one on every response it sees — so the cookie boundary already does most of the work.
That sentence is wrong in three different ways, none of which I noticed until prod was already serving cached pages across users.
The symptom
I was editing a feed in the app — flipping the filter mode,
adding a tag — and the changes weren’t showing up in the list.
The CDN was the most recent thing I’d changed, so I curled the page
with my session cookies and looked at the response headers.
cf-cache-status: HIT, non-zero age:. Cloudflare had cached the
page and was handing the stale copy back to my browser.
Worse: the cached page contained one user’s feed list. Anyone else
who hit / while that entry was still alive at the edge could see
it instead of their own. I don’t have evidence anyone actually got
someone else’s data, but the conditions for it to happen were there.
Three things I had wrong
gorilla/csrf does not set a cookie on every response. It only
writes Set-Cookie when generating a new token — when the
existing _gorilla_csrf cookie is missing or fails HMAC. Once a
visitor has a valid one, every subsequent response leaves origin
with no Set-Cookie at all. Most authenticated traffic falls on
the no-Set-Cookie side of that.
Cloudflare’s cache rule overrode the default Set-Cookie
bypass. Cloudflare normally refuses to cache responses with
Set-Cookie. The cache rule I wrote yesterday sets cache = true
explicitly, which is the documented way to force-cache responses
the default would skip. So even on the first hit, when there was
a Set-Cookie, the rule was force-caching it anyway.
Cloudflare’s cache key doesn’t honor Vary: Cookie. gorilla/csrf
adds Vary: Cookie to every response in its group, which is the
spec-correct way to tell shared caches that a response varies per
cookie. Cloudflare’s default cache key is scheme + host + path + query, and it does not extend that with Vary: Cookie. The header
passes through to the browser but, from Cloudflare’s perspective,
is decoration.
Each individual piece is documented if you go looking. The composition is what got me — three independent assumptions, each one of which would have been enough on its own, and all three of which turned out to be the opposite of what I’d assumed.
The fix
Authenticated routes default to Cache-Control: private, no-store
via a small middleware that runs first in the CSRF group. That’s
the explicit “don’t cache” signal the gorilla/csrf cookie was
supposed to be and isn’t. Cloudflare’s respect_origin mode
honours no-store, so the edge stops holding onto authenticated
HTML regardless of what other headers the response carries.
The anonymous lander came out of the CSRF group entirely. Yesterday’s
plan kept authenticated routes and the cacheable lander in the same
middleware chain on the assumption that the cookie boundary would
separate them; once the cookie boundary turned out not to exist,
the lander needed its own home. / now lives outside the CSRF
group, with handleRoot either rendering the lander for anonymous
visitors or 303-redirecting signed-in users to a new /feeds
route inside the auth group. The anonymous response goes out with
no Set-Cookie and no Vary: Cookie, and Cloudflare actually
caches it.
The Cloudflare cache rule grew a session-cookie guard so it only
matches requests without a session= cookie. Belt and suspenders
with the app’s no-store default: the rule’s cache = true
force-caching no longer applies to anything authenticated,
regardless of what the origin happens to send.
A regression test in cache_control_test.go asserts the lander
response has no Set-Cookie and no Vary: Cookie, so the
boundary is something written down rather than emergent.
What I’d take from this
Putting a CDN in front of a dynamic application isn’t a one-line config decision. It’s a contract between three independent moving parts — the framework’s response-header behaviour, the CDN’s caching rules, and the actual content boundary in your routes — and getting it wrong has correctness implications, not just performance ones. It deserves full attention and a verification pass through the edge, not a confident-sounding sentence in a deploy summary.
The concrete change for me is small: any caching-adjacent edit
now gets a curl through the CDN before I call it done. Looking
at cf-cache-status on a few representative paths takes about a
minute, and it would have caught this the day it shipped.









