FeedFilters
Pricing Log in Sign up

Blog

  • All posts
  • Subscribe (Atom)

Recent

  • Where the cookie boundary didn't May 12, 2026
  • A CDN for most of it May 11, 2026
  • Done, not abandoned May 8, 2026
  • There's no catch May 7, 2026
  • Sixteen mockups May 6, 2026
  • From scripts to infrastructure May 5, 2026
  • Doing mail myself May 4, 2026
  • Easier now than later May 2, 2026

Archive

  • 2026 15 posts

Where the cookie boundary didn't

May 12, 2026 · Kyle Cronin

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.

About· Help· Blog· Privacy & Terms
FeedFilters by Flat Six Software · © 2026