In short: On a cached WordPress site, decide consent in the browser, not in PHP: print the same hidden banner on every page, hold Jetpack Stats as inert text and start Tag Manager only after Accept. In a test, no third-party request was made before a choice.
Why does a consent check in PHP fail on a cached page?
A full-page cache, whether a CDN, a host cache or a caching plugin, normally stores one HTML response per address and serves it to every visitor, unless it is told to vary or bypass it. Anything PHP decides while building the page, such as "this visitor has accepted, so print the analytics scripts", is then decided once and repeated for everyone the cache serves.
A test with a page cache in front of WordPress showed it. A visitor who had accepted loaded the page first. The next visitor, who had made no choice, was handed the same stored page: Jetpack Stats as a live script, no banner, and the browser contacted the Stats host at once. Setting the consent cookie from PHP does not rescue the design: Cloudflare's default behaviour leaves HTML uncached and also skips any response that sets a cookie[1], so you would lose the cache for exactly those pages.
So the page must be identical for everyone, and the browser must decide, from a cookie that JavaScript sets and reads.
How do you stop GA4 and Jetpack Stats until the visitor accepts?
Five parts do the job:
- Print one hidden banner on every page, with the
hiddenattribute and the CSS rule.. Without the rule, a banner styled withconsent[hidden]{display: none} display:flexshows for visitors without JavaScript. - Hold Jetpack Stats as inert text. Jetpack prints a setup script and a script from its Stats host. WordPress lets you change the attributes of both through two filters, available since version 5.7[2][3]. With
type="text/each becomes a data block: the browser does not run it and ignores itsplain" src, so the file is not requested[4]. - Set the Tag Manager plugin to print the data layer only. In GTM4WP, the container placement "Off – container code turned off, data layer only" leaves the container code to you[5]. In the version we tested it also removed the noscript iframe, which otherwise loads Tag Manager for visitors without JavaScript.
- Start Tag Manager from your own script after Accept, after pushing consent defaults with ad storage, ad user data and personalisation denied. Google's advanced consent mode loads tags before the dialog and sends cookieless pings when consent is denied, which keeps some counting but contacts Google before a choice; its basic mode keeps tags blocked until consent[6]. This builds the basic one.
- On Reject, remove the Google cookies, and reload if the visitor had accepted before, because a script that is already running stops only on reload. Google sets its cookies on the parent domain, so every domain level is cleared.
The script stores the choice in a cookie named consent (1 or 0, six months), so the cached page never varies. Held scripts are activated once the page has been parsed, even if Accept is clicked earlier, or if the script itself is added after the page has loaded. The routine clears cookies at path /; if your tags use another path, add it and test.
Try this on a staging copy first, never straight on a live site, and take a backup before activating it. You use this information at your own risk, and iLogr accepts no responsibility for any loss or damage.
Save the first block as consent/consent.php in wp-content/plugins, the second beside it as consent.js, and activate the plugin. The theme must call wp_body_open().
<?php
/** Plugin Name: Consent banner */
defined('ABSPATH') || exit;
// Print the Stats scripts inert.
function consent_hold_analytics(array $attributes): array
{
if (in_array($attributes['id'] ?? '', ['jetpack-stats-js', 'jetpack-stats-js-before'], true)) {
$attributes['type'] = 'text/plain';
$attributes['data-consent'] = 'analytics';
}
return $attributes;
}
add_filter('wp_script_attributes', 'consent_hold_analytics', 20);
add_filter('wp_inline_script_attributes', 'consent_hold_analytics', 20);
// The same hidden banner on every page.
add_action('wp_body_open', static function (): void {
$options = get_option('gtm4wp-options');
$gtm = is_array($options) ? (string) ($options['gtm-code'] ?? '') : '';
?>
<section class="consent" id="consent" aria-label="Cookie choice" data-gtm="<?php echo esc_attr($gtm); ?>" hidden>
<p>With your permission, this site uses Google Analytics and Jetpack Stats to count which pages are read. Nothing loads unless you accept. <a href="<?php echo esc_url(get_privacy_policy_url()); ?>">Privacy policy</a></p>
<div class="consent__actions">
<button type="button" data-consent="0">Reject</button>
<button type="button" data-consent="1">Accept</button>
</div>
</section>
<?php
}, 1);
// The script decides what to show.
add_action('wp_footer', static function (): void {
echo '<script>' . file_get_contents(__DIR__ . '/consent.js') . '</script>';
}, 5);
(function (w, d) {
var box = d.getElementById('consent');
if (!box) { return; }
var saved = d.cookie.match(/(?:^|; )consent=([01])/);
var choice = saved ? saved[1] : null;
var opener = null;
var started = false;
function clearGoogleCookies() {
d.cookie.split('; ').forEach(function (pair) {
var name = pair.split('=')[0];
if (/^_ga/.test(name)) {
d.cookie = name + '=; max-age=0; path=/';
var parts = w.location.hostname.split('.');
while (parts.length > 1) {
d.cookie = name + '=; max-age=0; path=/; domain=' + parts.join('.');
parts.shift();
}
}
});
}
function runHeld() {
d.querySelectorAll('script[type="text/plain"][data-consent]').forEach(function (held) {
var script = d.createElement('script');
Array.prototype.forEach.call(held.attributes, function (attr) {
if (attr.name !== 'type' && attr.name !== 'data-consent') { script.setAttribute(attr.name, attr.value); }
});
script.text = held.text;
held.parentNode.replaceChild(script, held);
});
}
function startTagManager() {
var id = box.getAttribute('data-gtm');
if (choice !== '1' || started || !id) { return; }
started = true;
w.dataLayer = w.dataLayer || [];
w.gtag = w.gtag || function () { w.dataLayer.push(arguments); };
w.gtag('consent', 'default', { analytics_storage: 'granted', ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied' });
w.dataLayer.push({ 'gtm.start': Date.now(), event: 'gtm.js' });
var s = d.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtm.js?id=' + id;
d.head.appendChild(s);
}
function whenParsed() {
function run() { if (choice === '1') { runHeld(); } }
if (d.readyState === 'loading') { d.addEventListener('DOMContentLoaded', run, { once: true }); } else { run(); }
}
function open(from) {
opener = from || null;
box.hidden = false;
d.documentElement.style.setProperty('--consent-room', box.offsetHeight + 32 + 'px');
if (opener) { box.querySelector('[data-consent]').focus(); }
}
function save(value) {
var previous = choice;
choice = value;
d.cookie = 'consent=' + value + '; max-age=15724800; path=/; SameSite=Lax';
box.hidden = true;
d.documentElement.style.removeProperty('--consent-room');
if (opener) { opener.focus(); opener = null; }
if (value === '1') { whenParsed(); startTagManager(); return; }
clearGoogleCookies();
if (previous === '1') { w.location.reload(); } // running scripts stop only on reload
}
box.addEventListener('click', function (event) {
var button = event.target.closest('[data-consent]');
if (button) { save(button.getAttribute('data-consent')); }
});
d.addEventListener('click', function (event) {
var link = event.target.closest('a[href$="#cookie-settings"]');
if (link) { event.preventDefault(); open(link); }
}, true);
if (choice === '1') {
whenParsed();
if (d.readyState === 'complete') { startTagManager(); } else { w.addEventListener('load', startTagManager, { once: true }); }
} else if (choice === '0') {
clearGoogleCookies();
}
if (choice === null) { open(null); }
})(window, document);
The ids in the PHP are the script handle plus -js, and -js-before for Jetpack's inline setup script. To hold another plugin's script, find its tag in the page source and add its id. Style the banner yourself, fixed to the bottom of the screen, and add two rules to your stylesheet: . and html{scroll-padding-bottom:. The script sets --consent-room to the banner's height plus 32 pixels while the banner is open.
What did a test with Accept, Reject and a revisit show?
We ran this code on a fresh WordPress 7.1.3 install (PHP 8.3) with Jetpack 16.3.1 and GTM4WP 2.0.6, behind an nginx page cache that stored one copy per address. Jetpack Stats would not switch on without a wordpress.com connection, so the test called Jetpack's own tracking class to print the real Stats markup. The Google and Stats scripts were local stand-ins under a placeholder container ID: they show when the browser asks for them, not what the real scripts do. Every request to another host was logged.
| Visitor does | Third-party requests | Cookies stored |
|---|---|---|
| First visit, scrolls, presses a key, makes no choice | none | none |
| Clicks Accept | Stats script, Stats view, Tag Manager, Google collect | consent, _ga, _ga_ plus the container ID |
| Opens a second page after accepting | the same four; banner stays hidden | the same |
| Clicks Reject on a first visit, then opens a second page | none | consent only |
| Accepts, reopens the banner from a "Cookie settings" link, clicks Reject | page reloads; none during or after the reload | consent only |
| Visits with JavaScript off | none; the article is readable, the banner is not shown | none |
Three controls showed the checks can fail. With the Stats scripts left unheld, the Stats host was contacted before any choice. With GTM4WP's placement set to Footer, Tag Manager was contacted and the noscript iframe was printed. With the hidden rule left out, the banner showed without JavaScript. Four more cases ran on a synthetic page: Reject removing parent-domain Google cookies, an Accept clicked while the document was still loading, the consent script added after load, and a Reject before the load event. In the controls, the cookies survived Reject without parent-domain deletion, Stats stayed inert without readiness handling, and Tag Manager still started after a Reject without a start-up consent check.

Does Jetpack's own consent setting do the same job?
Not by itself, in our test. Version 0.20.0 of Jetpack's Stats package added a per-site setting that makes the Stats pixel honour the "statistics" category of the WP Consent API[7], a shared layer for consent that shows no banner itself[8]. With the setting on and no WP Consent API plugin installed, the page carried the consent gate, yet the Stats script was still requested and the view call still ran: Jetpack treats a missing consent plugin as permission. We did not test it with a consent plugin installed. To keep the Stats host untouched before a choice, hold the script as in step 2.
How do you make the banner usable with a keyboard and on a phone?
Measured in the same test:
- Print it first. With the banner at the start of the body, a keyboard visitor reached its first button on the first Tab press. Printed at the end of the page, where footer hooks put it, it took 70 presses on a page with 79 focusable elements.
- Keep room for focus. WCAG 2.2 criterion 2.4.11 (level AA) says a focused control must not be entirely hidden by content you author; the W3C names sticky cookie banners and scroll padding as a fix[9]. At 375 pixels wide with no room reserved, 14 of 40 links sat at least partly behind the open banner when focused. With the
scroll-padding-bottomrule above, none did. - Make it easy to use. Both buttons were 44 pixels high and share one style, and the banner took 27 per cent of a 667 pixel high screen. A link ending in
#cookie-settingsreopens it and moves focus to its first button, then back to the link after a choice.
An axe-core 4.10.2 run (WCAG 2.2 AA rules) found nothing in the banner at phone width. Automated checks cover only part of accessibility, and no screen reader was tested.
What does strict opt-in cost, and do you need it?
On a small WordPress site using this setup, Google Analytics recorded 14 sessions in one recent week, while a cookie-free counter at the CDN edge recorded 400 visits and 580 page views over the same days. The counters use different definitions and the edge figure includes some staff visits, so these totals do not give a consent rate or isolate the effect of opt-in. The practical point stands: once analytics waits for Accept, use Search Console and an edge counter for volume, and keep GA4 for what consenting visitors do.
Whether you need prior consent depends on where your visitors are and what you measure; this is a mechanism, not legal advice. The European Data Protection Board says the ePrivacy rule on accessing a visitor's device also reaches tracking pixels[10]. In the UK, the Information Commissioner's exception for statistics about how your own service is used does not cover tracking individual visitors or advertising measurement[11].
Which consent plugins fit if you would rather not write this?
Judge a plugin on how it behaves on a cached page. Does it print the banner hidden and show it with a script? Does its blocking reach scripts that other plugins print (Jetpack, GTM4WP)? Does it set Google consent mode defaults, and speak the WP Consent API?
For ad publishers one question decides it: will you show personalised Google ads to visitors in the EEA, the UK or Switzerland? Google requires a certified consent platform integrated with the IAB TCF framework there, from 16 January 2024 for the EEA and UK and 31 July 2024 for Switzerland[12]. A hand-built banner like this one is not a certified platform.
Two real options, as their plugin pages describe them: Complianz blocks scripts, iframes and plugins by category in its free plan, supports the WP Consent API and calls itself a Google-certified CMP, with TCF as a premium feature[13]. CookieYes blocks non-essential cookies automatically, supports Google consent mode v2 in its free plan and works with major caching plugins, with TCF in paid plans[14]. Compare current pages and prices.
How do you keep it working after updates?
After a plugin, theme or cache change, open the cached page in a clean browser profile with the network panel open and confirm four things: no third-party host before a choice, the expected hosts after Accept, none after Reject and a reload, and a readable page with scripts off.
References
Numbers in square brackets in the text link to these sources.
- Cloudflare Docs, "Default cache behavior" (opens in a new tab).Used for: what a CDN caches by default, and the Set-Cookie rule.
- WordPress.org,
wp_script_attributes(opens in a new tab).Used for: changing script tag attributes, and the 5.7 version. - WordPress.org,
wp_(opens in a new tab).Used for: changing inline script tag attributes, and the 5.7 version.inline_ script_ attributes - MDN, "script type" attribute (opens in a new tab).Used for: why a text/plain script is not run and its src ignored.
- WordPress.org, GTM4WP plugin page (opens in a new tab).Used for: container placement options and the noscript iframe.
- Google Analytics Help, "Consent mode overview" (opens in a new tab).Used for: basic and advanced consent mode, and cookieless pings.
- Jetpack Stats package changelog (opens in a new tab).Used for: the Stats setting that honours the WP Consent API, added in 0.20.0.
- WordPress.org, WP Consent API plugin page (opens in a new tab).Used for: what the WP Consent API is, and that it shows no banner.
- W3C, "Understanding SC 2.4.11" (opens in a new tab).Used for: the focus rule and its mention of sticky cookie banners.
- EDPB, Guidelines 2/2023 on Article 5(3) ePrivacy, version 2.0 (opens in a new tab).Used for: tracking pixels being in scope.
- ICO, "What are the exceptions?" (opens in a new tab).Used for: the statistical purposes exception and what it excludes.
- Google AdSense Help, certified consent management platforms (opens in a new tab).Used for: the certified CMP requirement for personalised ads, with dates.
- WordPress.org, Complianz plugin page (opens in a new tab).Used for: Complianz script blocking, WP Consent API support and its certification claim.
- WordPress.org, CookieYes plugin page (opens in a new tab).Used for: CookieYes blocking, consent mode, caching and paid features.