Cookies are blocked or not supported

Title card: Cookies are blocked or not supported

The site was up. The pages rendered. The certificate was clean and the DNS had settled hours ago. Then the client tried to log in to one of the subsites and got an error message that has been quietly wrong for about fifteen years: Cookies are blocked or not supported. You must enable cookies to use WordPress.

Their cookies were fine. Everyone’s cookies were fine. That message has almost nothing to do with your browser settings, and I wish WordPress would say so.

This is another one from the fleet migration — moving WordPress sites off Bitnami Lightsail onto AWS’s managed blueprint. Cutover had gone well. The site in question was a publishing network, five subsites in one multisite install, and three of those five sat on their own external domains rather than subdomains of the network’s primary. Front end: perfect on all five. Login: worked on the two that lived under the primary domain, dead on the three mapped ones.

What domain mapping actually is here

Worth a paragraph, because the whole bug lives in this detail.

A WordPress multisite normally puts every subsite underneath one parent domain. Either subdirectories (network.example/mag-two/) or subdomains (mag-two.network.example). Either way, one apex domain, everything hanging off it.

Domain mapping breaks that. You set wp_blogs.domain for a subsite to something completely unrelated — traveldigest.example, say — and that subsite is now served on its own apex while still living inside a network whose primary is publishingnetwork.example. Same database, same install, same wp-config. Different domain entirely.

This used to need a plugin. It’s been in core since 4.5, and it works well enough that you forget it’s doing anything clever. Right up until it collides with cookies.

What WordPress does with COOKIE_DOMAIN

If you don’t define COOKIE_DOMAIN yourself, WordPress sets it for you. The relevant code lives in ms_cookie_constants(), and it’s been sitting there since 2.0:

if ( ! defined( 'COOKIE_DOMAIN' ) && is_subdomain_install() ) {
    if ( ! empty( $current_site->cookie_domain ) ) {
        define( 'COOKIE_DOMAIN', '.' . $current_site->cookie_domain );
    } else {
        define( 'COOKIE_DOMAIN', '.' . $current_site->domain );
    }
}

A dot, then the network’s primary domain. And note the is_subdomain_install() gate — a domain-mapped network is a subdomain install as far as that check is concerned, so yes, this fires.

So every auth cookie WordPress issues, anywhere in the network, gets stamped Domain=.publishingnetwork.example. Which is exactly what you want for a subdomain network — that leading dot makes the cookie valid across every *.publishingnetwork.example subsite, and users get one login for the lot. Neat.

Now serve that same install on traveldigest.example. The user posts the login form. WordPress does its thing, authenticates them correctly, and sends back a Set-Cookie header scoped to .publishingnetwork.example.

The browser looks at that and refuses. It has to. Cookie scoping rules are one of the oldest bits of browser security there is: a response from traveldigest.example may set cookies for traveldigest.example and its parents, and nothing else. publishingnetwork.example is not a parent of traveldigest.example. It’s an unrelated site. So the header goes in the bin, silently, no console warning worth noticing.

The redirect fires. wp-login.php looks for the test cookie it just set, finds nothing, and concludes the only thing it knows how to conclude — that your browser doesn’t do cookies.

The authentication worked. The cookie was correct. It just wasn’t allowed to land.

Why it survived to production

Here’s the part I’d rather not have written down.

The mapped domains all resolved to the old server right up until cutover. On the old box this had been fixed — by hand, years ago, by someone (me) patching wp-config and then never thinking about it again. Migration copies the database and the files. It doesn’t copy the hand-edits somebody made to a config file in 2019 and then forgot to write down.

The new instance got a freshly generated wp-config from my provisioning script. Correct salts, correct database credentials, correct everything the script knows about. No COOKIE_DOMAIN, because the script had never been told that was a thing worth defining.

And of course the front end was flawless, so the smoke test passed. You don’t discover this by loading the homepage. You discover it when a real person tries to sign in, which on a cutover is usually somebody else, usually about an hour after you’ve told them it all went fine.

The fix

One constant.

if ( ! defined( 'COOKIE_DOMAIN' ) ) {
    define( 'COOKIE_DOMAIN', $_SERVER['HTTP_HOST'] );
}

Scope the cookie to whatever host the request actually arrived on. A request to traveldigest.example sets a cookie for traveldigest.example, the browser is happy, login works. A request to the primary domain sets one for the primary domain. Each mapped site owns its own session.

Note the missing dot. Without a leading dot the cookie is host-only — it applies to exactly that hostname and no subdomains. That’s what you want here.

The trade-off is real but small: you lose network-wide single sign-on. Log in to one subsite, you’re not automatically logged in to the next; you get a session per host instead of one for the whole network. For a set of separately-branded publications with different editors, that’s arguably the correct behaviour anyway. If you genuinely need shared login across mapped domains you’re into SUNRISE and cross-domain auth token territory, and that is a different post that I hope I never have to write.

It’s safe to set on a pure subdomain multisite too. Same trade — per-site sessions instead of network-wide — but nothing breaks.

Making the machine remember it

Patching the running instance took two minutes. That wasn’t the point. The point was that the next multisite through the pipeline would do exactly the same thing, and I’d rediscover it exactly the same way, probably on a Friday.

So it went into the provisioning script, inside the branch that already detects multisite:

if ! sudo grep -qE "^\s*define\(\s*'COOKIE_DOMAIN'" "$WP_CONFIG"; then
    echo 'Injecting COOKIE_DOMAIN block'
    sudo sed -i "/Happy publishing/i \\
if ( ! defined( 'COOKIE_DOMAIN' ) ) {\\
    define( 'COOKIE_DOMAIN', \$_SERVER['HTTP_HOST'] );\\
}\\
" "$WP_CONFIG"
else
    echo '    COOKIE_DOMAIN already defined, skipping'
fi

Two details in there that cost me more time than the bug did.

The grep -qE guard makes it idempotent. These scripts get re-run — on a retry, on a retrofit, on a box somebody already half-fixed by hand — and a block that appends itself every time is how you end up with a wp-config full of duplicate defines and a fatal error at the bottom.

The insertion anchor is Happy publishing, not the more obvious line above it. Stock wp-config has this near the end:

/* That's all, stop editing! Happy publishing. */

Anchoring a sed command on That's all, stop editing means putting an apostrophe inside a shell double-quoted string that’s already escaping its way through sed and into PHP. It can be done. It is not worth doing. Happy publishing is on the same line, unique in the file, and contains nothing that needs escaping.

The thing worth keeping

When WordPress tells you cookies are blocked, it is telling you it set a cookie and didn’t get it back. That is all it knows. The browser is almost never the culprit — look at what domain the cookie was scoped to, and what domain the request came in on. If those two don’t line up, you’ve found it.

More generally: the config on a long-lived server is not the config in your repository. Every box that’s been alive a few years has some undocumented hand-edit holding something together, and a migration is precisely the event that throws it away. The front end won’t tell you. It’ll look perfect, right up until somebody tries to log in.

Leave a Reply

Your email address will not be published. Required fields are marked *