Applies to
legal.cookie-banner, legal.no-tags-before-consent.
Covers legal.cookie-banner, legal.no-tags-before-consent. Related: perf.third-party,
comms.conversion-events.
A cookie banner is a consent mechanism only if refusing costs the same as accepting and nothing loaded before the visitor chose. Miss either half and the banner is decoration with a fine attached, which is why both rules are blockers and why they fail together.
What CNIL says, and what these rules add
Two sentences from the consolidated cookies recommendation carry the pair. Section 2.3, on refusal:
Le responsable de traitement doit offrir aux utilisateurs tant la possibilité d'accepter que de refuser les opérations de lecture et/ou d'écriture avec le même degré de simplicité.
And the chapeau to section 2, which is the one people miss:
Toute inaction ou action des utilisateurs autre qu'un acte positif signifiant son consentement doit être interprétée comme un refus de consentir ; dans ce cas, aucune opération de lecture ou d'écriture soumise au consentement ne peut légalement avoir lieu.
No choice is a refusal. Not a pending state, not a grace period, not "until they scroll". The recommendation restates a binding norm, article 82 of the loi Informatique et Libertes, so the answer to a client is "the law says, and here is the regulator explaining it". Both rules then add a measurable the document does not carry, which is worth saying out loud first.
| The recommendation says | The rule measures |
|---|---|
Accept and refuse avec le même degré de simplicité, buttons and font de même taille [...] mis en évidence de manière identique |
Refuse in one click from the first screen, same size, weight and contrast as accept |
| No read or write subject to consent before a positive act | 0 requests to any analytics or marketing host before a choice |
A choice honoured for six months, offered as a bonne pratique |
Not graded. Named in the rule text and left there |
| Nothing about how many tag containers a site runs | Exactly one container |
The last row is ours with no source behind it: a second container is a second ungated door a client can add a tag inside without touching the repository. The six-month row hides a trap, because the starter theme's own consent cookie runs twelve months, so a build that keeps the default sits outside the guidance the rule itself quotes.
The fines are about the second click, not the missing banner. CNIL's deliberations of 31 December 2021 were EUR 150 million against Google and EUR 60 million against Meta, and both sites had a banner. Those are separate deliberations from the recommendation; this standard reads the asymmetry as the finding.
The banner that is decoration
The common shape is a tag container in the head with the consent banner inside it as a tag, so the
container that needs gating is the thing that delivers the gate. By the time the banner renders,
everything in the container has been fetched. This was measured: neither of the two builds this
standard was audited against gates a single tag on consent, zero consent reads in either tag path,
and both exceed one container, one running two plus a call-tracking widget, the other three
analytics stacks fired on idle after load. In one the ordering cannot be fixed at all, because the
banner is a tag inside the container it would have to block, and that build also has a consent API
plugin installed and never called. Ship the banner from the theme instead: a container-delivered
one cannot appear until the container downloads, leaving the visitor bannerless for seconds on a
live page, and wp-starter/cookie-bar renders from the footer for that reason.
The other thing that looks like a gate is a server-side branch on the consent cookie. Behind a page
cache the first anonymous visitor's HTML becomes everybody's HTML: if they accepted, the cached
page ships tags to people who refused; if they refused, nobody is measured. It is invisible
locally, where no cache sits in front of you. Render identical markup for every anonymous visitor
and create the script element in JavaScript after the choice. wp-starter/analytics-registry
enforces that rather than documenting it, forbidding server-side reads of the consent cookie
outside two uncacheable call sites.
Two failures are not code at all. A hosted consent platform keeps its buttons in a dashboard, so a
repository review cannot pass legal.cookie-banner, a redeploy does not undo a change made there,
and git history is no evidence: grade it by eye on the live site. And container access lets the
client add an ungated tag the day after launch, so name who holds it in the credential inventory
delivery.client-handover asks for.
What counts as strictly necessary
The clause these rules cite does not define the exempt set; article 82 does. The line below is this standard's own position, not a quotation, and it is what to argue from.
Exempt: the cookie storing the consent choice, session and authentication, cart and multi-step form state, a CSRF or nonce token, load balancing, and a language the visitor explicitly picked. Not exempt, whatever the vendor calls it: analytics of any kind including self-hosted and "anonymised", A/B testing, personalisation, heatmaps, and a chat widget that loads before anyone clicks it.
For a disputed one the test is an experiment, not a definition: turn it off, browse as someone who refused, see what breaks. And note the first item on that list. Refusing must still write something, or the banner re-asks on every page and turns a no into a maybe.
Testing that nothing fired
The measurable is a network log, so test it with one, matching on hosts rather than on script tags.
const TAG_HOSTS = ["googletagmanager.com", "google-analytics.com", "doubleclick.net",
"connect.facebook.net", "hotjar.com", "clarity.ms"];
const isTag = (u) => { const h = new URL(u).hostname;
return TAG_HOSTS.some((t) => h === t || h.endsWith(`.${t}`)); };
test("no tag fires before a choice", async ({ browser }) => {
const page = await (await browser.newContext()).newPage(); // fresh storage, every run
const hits = [];
page.on("request", (r) => isTag(r.url()) && hits.push(r.url()));
await page.goto("/", { waitUntil: "networkidle" });
await page.mouse.wheel(0, 4000); // scroll triggers
await page.waitForTimeout(6000); // idle and timer triggers
expect(hits).toEqual([]);
});
Five things separate a test from a green light:
- A fresh context every run. A stored choice makes it pass forever, starting on your second run.
- Wait past load. One audited build fires its three stacks on idle after load, so asserting at
loadis green while the site is not. - Hostnames, not elements. Beacons leave as images and as
fetch;sendBeaconleaves nothing in the DOM. - Consent Mode
deniedis not a pass.gtag.jsstill downloads and still pings Google. Counting requests is what makes the rule stricter than its clause, which bans operations in the terminal, not requests on the wire. - Run all three states. Zero before a choice, still zero after refusing, non-zero after accepting. Only the third proves the assertion can fail at all.