Wire / Cookie Banners / Article
┌── POST 09.11 · Cookie Banners · 8 min read

The WP Consent API Fails Open: What WordPress Plugins Check Before Setting a Cookie

A consent banner cannot stop a WordPress plugin that sets its cookie in PHP. The WP Consent API is the interface for that problem, on roughly 200,000 sites — but with no CMP registering a consent type it returns true for every category, its new service-level action hook is missing from the shipped code, and its five categories do not map onto Consent Mode's seven signals.

TL;DR

A banner can block a script it recognises. It cannot block a WordPress plugin that sets its cookie in PHP before any JavaScript runs. The WP Consent API is the standard interface for that problem, on roughly 200,000 sites. Three things before you rely on it: with no consent management plugin registering a consent type, wp_has_consent() returns true for every category by design; the service-level consent added in 2.0.0 documents an action hook that is absent from the shipped code; and its five categories do not map cleanly onto Consent Mode’s seven signals.

Shopify’s Customer Privacy API solves consent centrally rather than leaving it to every app. WordPress has the same idea, and an older implementation: the WP Consent API, one interface through which a plugin asks whether it may store something and a consent management plugin answers.

Version 2.0.1 shipped on 19 August 2026. The wordpress.org plugin API reports around 200,000 active installations, and the repository — GPL-2.0, in the WordPress GitHub organisation — still describes it as “planned for adoption to WordPress Core”, a plan now six years old. Everything below was read off the released 2.0.1 zip, because the interesting parts are not in the documentation.

The default answer is yes

Consent resolves from two inputs: a region-based consent type, and the visitor’s stored choice. The consent type is a bare filter with no shipped value — apply_filters( 'wp_get_consent_type', '' ) — which a consent management plugin hooks to answer optin or optout. If none does, wp_has_consent() takes its first branch:

if ( ! $consent_type ) {
    // If consent_type is not set, there's no consent management, we should
    // return true to activate all cookies.
    $has_consent = true;
} elseif ( strpos( $consent_type, 'optout' ) !== false
           && ( ! isset( $_COOKIE[ $cookie_name ] ) ) ) {
    $has_consent = true;
}

The JavaScript half contains the same logic against window.wp_consent_type. This is deliberate and documented — the readme says “if no cookie banner plugin is active, the Consent API will always return with consent (true)” — and it is the right call for a library that would otherwise break analytics everywhere the moment it was activated.

It is also the most important operational fact about it. Installing the WP Consent API gates nothing. It is a wire, not a switch: if your CMP does not hook that filter, every plugin that dutifully checks wp_has_consent( 'marketing' ) gets a green light. Read the second branch twice too — in optout mode a visitor with no consent cookie counts as consenting.

To check a live site, run consent_api.consent_type || window.wp_consent_type in the console. Empty or undefined means nothing is gated.

The other half: who is listening

A plugin declares that it honours the API with one filter keyed to its own basename:

$plugin = plugin_basename( __FILE__ );
add_filter( "wp_consent_api_registered_{$plugin}", '__return_true' );

That declaration is what the plugin’s Site Health test reads. Under Tools → Site Health → Status it lists every active plugin that has not declared support, under the label “One or more plugins are not conforming to the Consent API” — the fastest inventory of what sits outside it.

The readme names 18 consent management providers that can set the consent type, against seven plugins on the consent-requiring side. The supply of plugins that can speak is not the constraint.

What 2.0.0 added, and two rough edges in it

Service-level consent is the headline: consent per service, not just per category, so a visitor can accept statistics in general and still refuse one analytics service. Three functions exist on both sides — wp_has_service_consent(), wp_is_service_denied(), wp_set_service_consent() — over one cookie, wp_consent_consented_services, holding a JSON map of service to boolean. A service with no entry inherits its category’s answer; an unregistered one counts as marketing. The two read functions are not complements: wp_is_service_denied() returns false for a service nobody has decided on, so “not denied” is not “consented”.

The documented action hook does not exist

The readme and the 2.0.0 changelog both tell you to listen with add_action( 'wp_consent_service_changed', … ). Grep the released zip for that string and it appears in exactly one file: readme.txt. It is in no PHP file and no JavaScript file, minified or not. What actually fires is

do_action( 'wp_consent_api_status_change_service', $service, $consented );

with a DOM event of the same name client-side, carrying e.detail.service and e.detail.value. Use the long name; the documented one fails silently, which is the worst way for a callback to fail.

A service decision written in PHP is invisible to JavaScript

PHP stores the map with setcookie(), and the PHP manual is explicit that “the value portion of the cookie will automatically be urlencoded and decoded by PHP”. So the stored value is percent-encoded: wp_consent_consented_services=%7B%22wp-statistics%22%3Afalse%7D.

The JavaScript reader pulls the raw string out of document.cookie and hands it to JSON.parse() with no decode step. That throws, the try/catch substitutes an empty object, and the reader falls through to the category answer — silently discarding the service decision. The reverse direction works, because the JavaScript writer stores unencoded JSON and PHP’s automatic decode is a no-op on a string with no escapes.

So set service consent from JavaScript. In DevTools, a value starting %7B rather than { means PHP wrote it and the client cannot read it. Both rough edges here are read off the shipped source plus documented PHP behaviour; we have not run them against a live install.

Mapping five categories onto seven Consent Mode signals

If Google tags are in scope, the WordPress model and Google’s stop lining up: five categories, seven signals.

WordPress category Consent Mode signal
functional functionality_storage
preferences personalization_storage
statistics analytics_storage
statistics-anonymous nothing — see below
marketing ad_storage, ad_user_data, ad_personalization
no equivalent security_storage (grant it in your defaults)

Do not grant analytics_storage on statistics-anonymous. The plugin defines that category as first-party storage used “exclusively for anonymous statistical purposes” that does “not allow identification of particular individuals”. A GA4 client ID identifies a returning browser, which is what it is for: the category describes a first-party counter, not GA4 with cookies on.

You cannot build the default call from this API

The library’s script is enqueued in the footer, at priority PHP_INT_MAX - 100, because it must load last so a CMP can declare a dependency on it. Correct for its purpose, fatal for Consent Mode: gtag('consent', 'default', …) has to run in the head, where wp_has_consent() does not exist yet.

Keep the two jobs separate. Put a static default call, everything denied except security_storage, in the head, then sync from the API once it exists:

function syncConsentMode() {
  if ( typeof wp_has_consent !== 'function' ) return;

  var ads   = wp_has_consent( 'marketing' );
  var stats = wp_has_consent( 'statistics' );

  gtag( 'consent', 'update', {
    ad_storage:              ads   ? 'granted' : 'denied',
    ad_user_data:            ads   ? 'granted' : 'denied',
    ad_personalization:      ads   ? 'granted' : 'denied',
    analytics_storage:       stats ? 'granted' : 'denied',
    functionality_storage:   wp_has_consent( 'functional' )  ? 'granted' : 'denied',
    personalization_storage: wp_has_consent( 'preferences' ) ? 'granted' : 'denied'
  } );
}

// a choice being made, a late region lookup, and every ordinary page load
document.addEventListener( 'wp_listen_for_consent_change', syncConsentMode );
document.addEventListener( 'wp_consent_type_defined', syncConsentMode );
window.addEventListener( 'load', syncConsentMode );

All three listeners matter. wp_listen_for_consent_change fires only when a value changes, so on its own it leaves a returning visitor whose cookie is already set sitting on the denied defaults — the same failure mode as wiring a CMP’s first-consent callback and nothing else.

Do not render that default call server-side from $_COOKIE either. It works in development and breaks in production: with a full-page cache, the first visitor’s consent state is cached and served to everyone after them.

Two smaller things the source tells you

The same cookie gets different attributes depending on which side wrote it. Both PHP writers pass only a path — no secure, no HttpOnly, no SameSite, which per the manual means none is sent. The JavaScript writer adds secure on HTTPS and also omits SameSite. The default lifetime is 30 days, on the category cookies and the services cookie alike, filterable through wp_cookie_expiration — a separate clock from whatever your CMP stores. The expiry string comes from Date.toGMTString(), which sounds alarming after what Safari does to a badly formatted Expires value, but is fine: MDN confirms it is “an alias to toUTCString“.

Category precedence is not what its comment claims. The resolver is documented as returning “the one with most privacy impact” for a service with cookies in several categories, and implements that by reversing the configured list — giving marketing, statistics-anonymous, statistics, preferences, functional. So a service registering both statistics and statistics-anonymous resolves to the latter, the more permissive of the pair. Keep those two under separate service names.

What it still does not do

The boundary is the one every consent tool has: it governs first-party storage by plugins that opted in. An iframe pasted into a template, a script in the theme header, a tag added in Tag Manager — none go through wp_has_consent(). Nor is there a server-side consent log; what exists is a cookie in the browser. Which leaves one way to know what your site does before consent: from outside. Read the signals off the network tab rather than trusting a settings screen.

Two plugins can disagree about whether a visitor consented, and both can report themselves as compliant. Check what your Consent Mode signals actually say on page load with CookieInspector.

C
About the author
Consent Mode HQ
Editorial team at Consent Mode HQ
Read more by author ↗