D
P
0
← All articles Baca dalam Bahasa Indonesia

Caching & CDN: Deployed, but Nothing Changed

The Bypass Rule Existed and the Header Still Said cf-cache-status: HIT. Cloudflare Cache Rules Are Last-Match-Wins

· · 10 min read
The Bypass Rule Existed and the Header Still Said cf-cache-status: HIT. Cloudflare Cache Rules Are Last-Match-Wins

The header said cf-cache-status: HIT, and it said it on exactly the pages that must never be cached: sign in, register, account. A Bypass rule for the auth paths already existed on that zone. The first question I chased was the predictable one, whether the expression itself was wrong.

The case closed with three accurate answers to three wrong questions.

One thing belongs up front, because it shaped how I worked the whole case. The Cloudflare zone was not mine. Cache Rules changes were recorded as the job of the person on the client side who holds Cloudflare, so there were only two things I could actually do: measure from the headers as an anonymous visitor, and hand over the correct list of paths to be installed there.

The damage does not look like cache damage

This is a WordPress site with a membership area sitting behind Cloudflare. Its auth pages bake a form timestamp straight into the HTML, and nonces on pages cached longer than 12 to 24 hours break too without a bypass.

When the sign in HTML really did get cached, this is what was measured: cf-cache-status: HIT, with the timestamp inside that HTML already 2512 seconds old. The window still treated as reasonable was 1800 seconds, so the bot check concluded the submission came from a bot. On the sign in page that meant the submission was refused with a retry error. In the profile claim flow it was worse, and this is the nastiest part of the whole thing: the submission was treated as a bot and then answered with a fake success, silently, with no signal at all to the person.

The code side fix was simple. That 1800 second stale window was raised to 12 hours and made filterable, matched to the nonce lifetime.

What makes a bug of this shape hard to see is how the caches treat someone who is logged in. A logged-in admin bypasses both cache layers, so the editor sees the new version while the public sees the old one, and the symptom reads like work that still does not match the mockup. The same effect was measured on the profile owner dashboard: its header came back DYNAMIC, because Cloudflare bypasses on the logged-in cookie rather than on the path.

The first wrong question: is the rule's content correct

The assumption I walked in with was that the bypass rule already covered roughly the same paths as sign in, account, and claim. That assumption was false, and it is recorded as false in the project notes. The sign in, register, claim, account, lost password, and two form submission paths had never been in the bypass rule at all. What lived there was only the WordPress standard set.

So the first step was not debugging, it was writing a list. I logged the bypass request to the infra side as an outstanding ask with an explicit list of paths. The first draft I handed over had eight paths in it. On review that list was still three short: the password reset page, the edit profile page, and the profile request page. Eight plus three, eleven.

The right question: which rule wins

The turn came from one small piece of evidence that refused to fit any story. The password reset path was already inside the Bypass rule's expression, and its header was still HIT. If the contents of a rule are correct and the outcome is still wrong, then "is this rule correct" was never going to lead anywhere. The question that should have come first: out of every rule that matches this URL, which one wins?

Cloudflare Cache Rules are evaluated last-match-wins. The winner is not the first match, it is the last rule that matches. On this zone the Bypass rule sat above the public page cache rule, and that cache rule was the one that won, one year TTL included.

Before
1. Bypass cache        (auth path list)
2. Cache public HTML   (edge TTL 1 year)   <- also matches, and it is LAST
 
After
1. Cache public HTML   (edge TTL 1 year)
2. Bypass cache        (auth path list)    <- now it is LAST

So the Bypass rule was correct and ignored at the same time. Those two things never contradicted each other, and for as long as I believed they did, I kept rereading a field that did not need fixing.

That TTL number is not an ordinary one. The site's HTML is served through Cloudflare with a header like this:

cf-cache-status: HIT
cache-control: max-age=31536000

31536000 seconds is one year, and it lands on the HTML, not on an image or a stylesheet.

While that TTL holds, a purge at origin touches nothing

The most expensive consequence of a one year TTL is not on the auth pages, it is on every deploy. Clearing the page cache plugin at origin is not enough. The public URL keeps serving the stale edge copy with cf-cache-status: HIT until Purge Everything runs on Cloudflare. In the project's activation notes that step ended up written down as mandatory, with the blunt remark that clearing the plugin on its own is useless.

Misunderstanding this is not cheap. In a single session it cost around six cache cycles.

Two incidents are a reminder that it can fool anyone. Once it was me complaining that the account page was empty, reachable without logging in, and still showing the old nav, when the server was already correct and what was stale was the browser and edge cache. The cure was a Cloudflare purge, a plugin clear, and a hard refresh. On another occasion the report came from the client side instead, saying the page looked unchanged, and the reply confirmed the cache was on their own side.

Reading headers without letting your own check lie to you

Before going further I needed a check that would not lie to me, and the old habit was the source of a fresh problem. The ?nowprocket=1 parameter gets edge-cached under its own cache key. Reusing the same parameter does not fetch the current state, it serves a stale copy of my own earlier diagnostic. If you have to use it, change the value on every check, &v=2 and so on, so the key changes with it.

What I settled on was the plainest route available, run from the browser console while logged out:

const r = await fetch('/signin/?cb=' + Math.random(), {
  cache: 'reload',
  credentials: 'omit',
});
console.log(r.headers.get('cf-cache-status'), r.headers.get('age'));

credentials: 'omit' makes sure I am read as an anonymous visitor. cache: 'reload' takes the browser cache out of the equation. Then it is cf-cache-status and age that get read, not the rendered page. A random ?cb= cache buster forces a MISS all the way to origin, which is what you want when the thing you need to prove is that the file really did deploy.

There is one convenience here that is easy to miss. A genuinely new URL has never been cached, its first hit is a MISS to origin, so new pages are testable before any purge. Existing URLs have to be purged first.

The second wrong question: what is special about this path

With the rule order flipped and the missing paths appended, I swept everything again. Almost all of it changed. One did not: the sign in page stayed HIT, alone.

The next question looked reasonable, what is special about this path that makes it behave differently from its ten siblings. The answer was not in the complicated layers. What settled it came from the client side: the person holding Cloudflare sent a screenshot of the rule itself, and in that screenshot the path field read /signins/, with one extra letter s.

The rule had never failed. It had simply never matched anything, because no page by that name exists. The right question turned out to be extremely boring: what is literally written in that field, character by character.

The third wrong question: is BYPASS good news

One more trap almost convinced me I was finished when I was not. Several pages came back with cf-cache-status: BYPASS, which is easy to read as proof that the Bypass rule is working.

That was not why. Those pages return a 302 for anonymous visitors, and origin sends no-store on the redirect response. Cloudflare skipped the cache because origin told it to, not because of the rule. The word BYPASS there is describing origin behaviour.

Under Cache Rules, a path that is genuinely being bypassed shows DYNAMIC, not BYPASS. Reading the wrong word as the success signal is what left the page that was actually broken sitting there on HIT.

Verification stricter than instinct asks for

I made the final sweep stricter, because all three earlier mistakes had survived checks that ran only once.

All eleven auth paths had to come back DYNAMIC and stay stable across three consecutive checks. Once is not enough, because a MISS followed by a HIT can look convincing if the only one you catch is the first.

Then the opposite side, which is far easier to forget. The homepage and the public listing page still had to return MISS on the first request and HIT on the next. That is the proof that public caching survived and the bypass rule is not over-matching. Fixing the auth pages while switching off caching for the whole site is not a fix, it is a swap.

With that sweep the status was closed on 3 July 2026 at around 18:00, eleven out of eleven, verified from headers rather than from how the pages looked. Three days later it was checked again and still stood at eleven out of eleven. The stale-nonce cache breakage class was declared closed.

The parts I have to correct myself on

This story would be too tidy if it stopped there, so here are two corrections.

First, not everything I suspected of being cache was cache. My audit notes carried a hypothesis that the one year HTML cache was caching the account page and stripping Set-Cookie. The QA evidence partly contradicted it: the login POST to admin-post.php came back cf-cache-status: DYNAMIC with no-store, meaning it was not cached, and /wp-admin/, which Cloudflare never caches, also bounced to the login page. The real cause was not cache at all. A lockdown running on admin_init had no bypass for admin-post.php, so every POST from a logged-out visitor was redirected before its handler ever ran. That is why the database had zero members on record.

Second, the state of the zone changed afterwards. My recommendation to the client side was that a one year HTML TTL is too aggressive. A measurement on 9 August 2026 showed every content page coming back DYNAMIC with max-age=0, so the edge holds no HTML at all any more. Whatever holds a stale page moved to the plugin cache at origin, with a ten day lifespan, which means an unpurged page can stay wrong for a week and a half.

One warning comes attached to that new state. This zone has no cache_everything page rule, and without one the plugin's auto purge by URL returns early. So if someone ever adds a Cache Rule for HTML on this zone, the auto purge failure becomes silent. While I am at it, a note of my own that was wrong: the plugin's Cloudflare add-on is in fact fully configured here, and the old claim that the token was missing was false.

What I actually took away

Three accurate answers to three wrong questions. Is the expression correct? It was, and it was irrelevant, because another rule won. What is special about this path? Nothing, because what was written in that field was not that path. Does BYPASS mean the rule is working? No, because that word came from origin.

Whenever a configuration rule appears to be ignored while its contents are correct, the useful question is not "is my rule right". It is "how does this system evaluate rules, and who wins when more than one matches". For Cloudflare Cache Rules, the answer is the last one.

And when a rule is ordered correctly and still touches nothing at all, stop theorising for a moment. Go read what is literally written in that field, letter by letter, and ask for a screenshot from whoever holds the dashboard if that is what it takes.