If Cloudflare serves your WordPress homepage with someone’s admin bar on it, or the wrong page to everyone, the edge is caching HTML it should not. This covers the missing cookie bypass, the password cookie WordPress actually sets, Vary, unkeyed headers, and a check you can run in two commands.
The one rule
A cache stores a response under a key and serves it to every later request with the same key. So the whole class of bug is one question: did two requests that deserve different answers produce the same key? If they did, whichever arrived first wins, for everybody, until the entry expires.
Cloudflare’s docs say plainly that the CDN does not cache HTML or JSON by default. If your HTML is cached at the edge, somebody turned it on with a Cache Rule. On this site, that somebody was me.
The missing cookie bypass
WordPress serves a different page to a logged in user, and the only thing marking that user on the front end is a cookie. Cookies are not in Cloudflare’s default cache key. So with HTML caching on and no cookie bypass, one logged in request can store a page with the admin bar. Every anonymous visitor then gets it until it expires.
The usual mistake is writing the bypass from the cookie constants in core:
define( 'PASS_COOKIE', 'wordpresspass_' . COOKIEHASH );
define( 'AUTH_COOKIE', 'wordpress_' . COOKIEHASH );
define( 'SECURE_AUTH_COOKIE', 'wordpress_sec_' . COOKIEHASH );
define( 'LOGGED_IN_COOKIE', 'wordpress_logged_in_' . COOKIEHASH );Two traps in that list. PASS_COOKIE is legacy: the only thing core still does with it is expire it. A password protected post sets wp-postpass_ plus the hash instead, written in wp-login.php and read back by post_password_required(). Build the rule from the constants and it matches a cookie nothing sets, while the real one goes straight past. It’s a feature not a bug.
The second trap is quieter. wordpress_ and wordpress_sec_ are only set on the admin and plugins paths, so a front end request never carries them. On the front end, the cookie that marks a logged in user is wordpress_logged_in_.
The prefixes worth matching on:
wordpress_logged_in_-> logged in userswp-postpass_-> password protected postscomment_author_-> returning commenters, email and URL cookies included- your commerce plugin’s cart and session cookies, whatever they are called
And one that is not worth matching: wordpress_test_cookie. The login screen sets it to the value WP Cookie check to see whether cookies work at all. It says nothing about who you are. Bypassing on it only throws away cache hits.
This site’s rule bypasses on wordpress_logged_in_, comment_author_ and wp-postpass_, plus preview=true and the admin, login, cron, XML-RPC and REST paths.
Should the origin send Vary: Cookie?
No. Vary: Cookie means one stored copy per distinct Cookie header, and once an analytics script sets an ID, every visitor has a distinct one. That is not a cache. That is a very expensive way to never get a hit.
Cloudflare has honoured Vary through Cache Rules since July 2026, on every plan, with three actions per header: normalize, passthrough and bypass. The docs list bypass for headers with too many possible values or per-user values. A response with Vary: * is never cached at all. So bypass on the cookies, and do not try to build a variant per person.
The real attack: unkeyed input
Everything above is a misconfiguration. Cache poisoning proper is different: a request header that is not in the cache key but still changes the response. An attacker sends one request with that header, and the origin renders a page pointing at their domain. The edge stores it under the ordinary key.
X-Forwarded-Host is the textbook example, and the earlier version of this post used it. Cloudflare’s default cache key already includes x-forwarded-host, along with x-host, x-original-url, x-rewrite-url and forwarded. So the textbook version does not work against a default key.
WordPress core is not the weak point either. It builds home URLs from the home setting, not from request headers. The risk is your own code: a wp-config.php snippet behind a proxy that copies a forwarded header into $_SERVER['HTTP_HOST'], or a plugin that reads one. If that header is not in the key, it is unkeyed input:
grep -rn "HTTP_X_" wp-config.php wp-content/plugins wp-content/themes | grep -v "/vendor/"Two things give a real poisoning away. The bad response is served to everyone, not only to whoever sent the odd request. And the trigger is a header, so nothing in the URL or the access log looks unusual.
How to check your own site
First, is the bypass working? Any cookie name starting with wordpress_logged_in_ will do:
curl -sI https://example.com/ | grep -iE 'cf-cache-status|^age|cache-control'
curl -sI -H 'Cookie: wordpress_logged_in_x=1' https://example.com/ | grep -i cf-cache-statusIf the second one says HIT, the bypass is not working and you have found it. On this site the first says HIT and the second DYNAMIC.
Second, is the stored copy personalised? Core adds a logged-in body class and renders the bar as <div id="wpadminbar">, and neither belongs in an anonymous page:
curl -s https://example.com/ | grep -cE 'id="wpadminbar"|<body[^>]* logged-in'Anything above zero is the answer. The earlier version of this check used grep -c 'a|b' without -E, which searches for a literal pipe. It returned 0 on every site, which is a very reassuring bug.
The fix order
Purge the origin cache first, then the edge, then warm it. The other order refills the edge from a stale origin and looks like the fix did not work.
Then keep forwarded headers out of your own code, and add a scheduled check that fetches the homepage anonymously and fails if it finds wpadminbar. That last one is ten lines, and it is the only part of this still protecting you in a year.

Leave a comment