Consent-sync fix: returning visitors get no Google consent update
Consent-sync fix: returning visitors get no Google consent update
Status: PREPARED, NOT APPLIED. Nothing in this document has been done on either live site. It waits for Dries’s approval. Written 4 Oct 2026.
The defect, in one paragraph
A returning visitor who accepted cookies more than 30 days ago gets no gtag('consent','update',…) on
page load. Every GA4 and Ads hit then goes out as gcs=G100 with a fresh client id. The cause is two
cookies that should agree but expire at different times:
- Complianz stores the choice in
cmplz_<category>cookies, valid for 365 days ("cookie_expiry":"365"). - WP Consent API keeps its own copy in
wp_consent_<category>cookies, valid for 30 days (consent_api = {"cookie_expiration":"30", "cookie_prefix":"wp_consent", …}). - Site Kit decides whether to push a consent update by calling
wp_has_consent(category), which reads only thewp_consent_*cookie. - Complianz writes
wp_consent_*only when its own cookie changes. Incomplianz.min.js7.5.5,cmplz_wp_set_consenthas exactly one call site, insidecmplz_set_consent, guarded byn!==t(the old value differs from the new one). Nothing re-syncs it on a later page load.
So on day 31 cmplz_has_consent('statistics') is still true, wp_has_consent('statistics') is false,
and Site Kit stays silent.
Confirmed in Dries’s Chrome on 4 Oct 2026:
- .be:
cmplz_has_consent('statistics')= true,wp_has_consent('statistics')= false, and there are nowp_consent_*cookies. - .com: both are true and
wp_consent_*exists. That accept is younger than 30 days.
.com will break the same way once its wp_consent_* cookies turn 30 days old. The fix goes on both
sites.
Script order is not the cause. Both sites serve the consent default first, then Site Kit consent-mode, then WP Consent API, then Complianz (deferred), and the three JS files are byte-identical across the sites (md5 checked 4 Oct).
The snippet (identical on both sites)
<script>
/* Consent sync: copy an EXISTING Complianz "allow" into WP Consent API (and so into Site Kit's
Google consent update). Never grants anything Complianz does not already hold; never denies.
Why: wp_consent_* cookies live 30 days, cmplz_* 365, and Complianz only re-writes wp_consent_*
when its own choice changes. See docs/consent-sync-fix.md. */
(function () {
var CATEGORIES = ['preferences', 'statistics', 'marketing'];
// The visitor's stored banner choice, read from the cookie itself.
// cmplz_has_consent() alone is not enough: it returns true for bots and
// always for 'functional', without any choice having been made.
function storedAllow(c) {
var prefix = (window.complianz && window.complianz.prefix) || 'cmplz_';
return document.cookie.split(';').some(function (p) {
return p.trim() === prefix + c + '=allow';
});
}
function sync() {
if (typeof window.cmplz_has_consent !== 'function' ||
typeof window.wp_has_consent !== 'function' ||
typeof window.wp_set_consent !== 'function') {
return; // a library is missing: do nothing rather than guess
}
CATEGORIES.forEach(function (c) {
if (storedAllow(c) && window.cmplz_has_consent(c) === true &&
!window.wp_has_consent(c)) {
window.wp_set_consent(c, 'allow');
}
});
}
// Normal path: Complianz has applied the stored choice to the page.
document.addEventListener('cmplz_fire_categories', sync);
// Late-load path: this snippet ran after Complianz had already initialised
// (wp_consent_type is set inside Complianz's init, just before it fires the event above).
if (window.wp_consent_type) sync();
})();
</script>
Why it is safe
- It never grants. It calls
wp_set_consent(c, 'allow')only when both of these hold:- the Complianz cookie
cmplz_<c>literally readsallow, which is the choice the visitor made in the banner; cmplz_has_consent(c) === true, which also respects Do Not Track, enabled on .com.
A visitor with no Complianz cookie, or one who denied, gets nothing. The cookie check is there because
cmplz_has_consentalone returns true for bots (cmplz_is_bot()) and always forfunctional, even when no choice has been made. Without that check the snippet would grant on a first visit. - the Complianz cookie
- It never denies. Mirroring a deny is left to Complianz, which already does it on change. (Decision I made: the request said “never grant”; I also kept it out of the deny path so the snippet can only ever restore Complianz’s own state, never invent one.)
functionalis deliberately left out. Complianz treats it as always allowed, so it carries no visitor choice to restore. Including it would grantfunctionality_storage/security_storageto visitors who never answered the banner. (Decision flagged: Complianz’s own first-accept path does writewp_consent_functional=allow, so after the fix a long-returning visitor gets those two types back only when they next change a choice. This does not affect GA4 or Ads.)- It is idempotent. Once the
wp_consent_*cookie exists,wp_has_consentis true and the snippet does nothing until the cookie expires again 30 days later. Then it repairs it again.
Guard checked against fakes (4 Oct 2026, Node, before any live change)
The snippet was syntax-checked with node --check. It was then run against a fake window/document
that models cmplz_has_consent, including the bot and functional shortcuts. Calls recorded to
wp_set_consent:
first visit, no choice []
bot, no choice []
denied all []
stats allow, mkt deny, wp expired ["statistics=allow"]
all allow, wp expired (the .be case) ["preferences=allow","statistics=allow","marketing=allow"]
all allow, wp present (the .com case) []
marketing withdrawn, wp deny []
This is a model of the libraries, not the libraries themselves. The live check is the Verification section below.
How it reaches Google, and the timing caveat
wp_set_consent(c,'allow')writeswp_consent_<c>=allowand, because the value changed, dispatcheswp_listen_for_consent_change.- Site Kit’s consent-mode script listens for that event and calls
gtag('consent','update',{analytics_storage:'granted', …}), using_googlesitekitConsentCategoryMapto map categories to consent types.
Order on the page. WPCode’s footer output is an inline script inside wp_footer. It runs while the
page is parsed, before the deferred complianz.min.js executes. Complianz’s init is top level and
synchronous on both sites ("geoip":"", so no XHR). The listener is therefore always registered before
cmplz_fire_categories fires. wp_set_consent is already defined by then, because wp-consent-api.js
is a normal (non-deferred) script earlier in the page. The if (window.wp_consent_type) sync(); line
covers the opposite order, for example if an optimiser ever moves the snippet after Complianz.
Residual risk, same as .com today. The update lands when the deferred Complianz script has run, just
before DOMContentLoaded. The consent default carries wait_for_update: 500, so gtag holds hits for up
to 500 ms.
- On a slow page: if Complianz takes longer than that, the first
page_viewcan still leave as G100, and later hits are G111. - No worse than .com: this is exactly where Site Kit’s own on-load check already runs on .com.
- Not fixable by this snippet: it cannot be fixed without moving consent ahead of gtag.js altogether, which is a larger change and out of scope here.
Install route per site
Neither site can take this without wp-admin. This was checked rather than assumed:
- WPCode has no WP-CLI command and no REST endpoint. WPCode Lite 2.3.9, downloaded from wordpress.org
on 4 Oct 2026:
grep -r WP_CLIover the plugin finds nothing.register_rest_routefinds nothing.- The
wpcodepost type is registeredpublic => false, show_ui => falsewith noshow_in_rest. - It also blocks
wp_insert_postwrites to that type from callers without its capabilities (wpcode_block_unauthorized_snippet_writes).
- A
wp post create --post_type=wpcodewould therefore skip WPCode’s own save path. That path covers the type, location and active meta, plus the snippet cache. Do not do it.
secondaire.verbomatique.be (Combell, self-hosted) — wp-admin
- SSH and WP-CLI are not usable. SSH is active but the only registered key (
rsa-key-20160719) is not on the Mac (knowledge file, “The Combell hosting, read properly”, 14 Sept 2026). Even with a key, see above: WPCode has no CLI. - WPCode is installed:
insert-headers-and-footers, among the 9 active plugins.
Steps:
secondaire.verbomatique.be/wp-admin/→ Code Snippets → + Add Snippet → Add Your Custom Code (New Snippet).- Code type HTML Snippet, so the
<script>tags above go in as written. - Title:
Consent sync: Complianz → WP Consent API (returning visitors). - Insertion: Auto Insert, location Site Wide Footer.
- Toggle Active, then Save Snippet. Note the snippet id it gets.
- There is no page-cache plugin on .be (the plugin list holds none), so there is nothing to purge.
verbomatique.com (WordPress.com Business, Atomic) — wp-admin
- WPCode is installed and active there: WPCode Lite 2.3.9,
insert-headers-and-footers/ihaf, status active. This was read live via the WordPress.com connector,plugin.list, on 4 Oct 2026. - “Code Snippets” is not installed. The knowledge file’s en-CA plugin line, which lists it, is stale.
- The connector cannot add a WPCode snippet. Its plugin tool installs and activates plugins; it does
not write plugin data. Its content tools write posts, pages and templates, not the hidden
wpcodetype. Plain REST cannot do it either (see above).
Steps:
- Run the same steps 1–5 in
verbomatique.com/wp-admin/. - Afterwards, check the served HTML is not altered by Page Optimize, which concatenates enqueued
scripts into
/_static/??…. An inline WPCode footer script should pass through untouched; the verification below confirms it. - The WordPress.com edge cache (
x-ac … MISS/HIT) may serve the old HTML for a short while. If the verification curl does not show the snippet, use Hosting → Performance → Clear cache or wait.
Verification (what I will run after you apply it)
1. The snippet is served (read-only, from this Mac):
curl -s https://secondaire.verbomatique.be/ | grep -c "Consent sync: copy an EXISTING Complianz"
curl -s https://verbomatique.com/ | grep -c "Consent sync: copy an EXISTING Complianz"
Expect 1 on each. It must appear before the cmplz-cookiebanner-js tag or, if after it, the
late-load path covers it.
2. Returning visitor, cookie expired: the case that is broken today. In Dries’s Chrome on .be, which
currently has cmplz_* and no wp_consent_*:
- Reload, then in the console run
dataLayer.filter(e => e[0] === 'consent'). Expect thedefaultentry and anupdatewithanalytics_storage: 'granted'andad_storage: 'granted'. wp_has_consent('statistics')should return true, and thewp_consent_statistics=allowcookie should now exist.- Network, filtered on
collect:region1.google-analytics.com/g/collectshould carrygcs=G111. - Reload a second time and check the
cid=value is the same on both reloads.
3. To reproduce on .com, where the cookie is still valid: in DevTools → Application → Cookies,
delete only the wp_consent_* cookies, keep cmplz_*, then repeat step 2.
4. First visit is unchanged. In an incognito window:
- Before clicking anything: no
updateindataLayer, nowp_consent_*cookies, hits atgcs=G100. - Deny / Weigeren: still no granted
update. - Accept: an
updategranted, as before. This is the existing Complianz path, untouched.
5. Withdrawal still works. After accepting, open the Complianz manage-consent tab and withdraw
marketing. wp_consent_marketing=deny should appear, and the snippet must not flip it back on the next
reload, because its guard is cmplz_has_consent('marketing') === true.
6. Fast regression signal (inferred, not yet measured): in GA4 → Admin → Data streams → Consent settings, the share of consented hits for nl-BE should rise in the days after the fix.
Rollback
On each site: wp-admin → Code Snippets → the “Consent sync” snippet → toggle Inactive (or Trash it). On .com, also clear the cache as above.
Effect of rollback:
- Behaviour returns exactly to today’s.
- The
wp_consent_*cookies the snippet wrote stay in visitors’ browsers until they expire. - That is harmless: they only mirror an
allowComplianz already held, and Complianz overwrites them on any later change of choice.
Not chosen, and why
- Raising WP Consent API’s cookie lifetime to 365 days. This does not repair visitors whose
wp_consent_*cookie has already expired, which is the case seen on .be. It would need a PHP filter on both sites as well, so it is more change for less effect. - Replacing Site Kit’s consent mode with Complianz’s own Google Consent Mode integration. It would work, but it changes which component owns consent on two live sites. That is a bigger decision than this defect needs.