Implementing Cookie Consent Cleanly, Technically
AI generated
OWASP
0x00
Security · Cookie Consent · Privacy · Compliance
Implementing Cookie Consent Cleanly, Technically
Opt-in, granular, and actually blocking, not just hidden

A cookie banner that lets tracking scripts keep running while the user has not yet consented satisfies neither ePrivacy nor GDPR nor TTDSG. This article shows how opt-in consent works granularly per purpose, how scripts get technically blocked until consent is given, how Google Consent Mode v2 and CMP platforms are correctly integrated, and how to actually verify your own implementation in the network tab.

14 min. read Opt-in · Consent Mode v2 · CMP GDPR · TTDSG · ePrivacy

1. Why cookie consent must be opt-in and granular

Under section 25 of Germany's TTDSG and Article 6 GDPR, consent for non-essential cookies and tracking scripts must explicitly be designed as opt-in. The Court of Justice of the European Union clarified in the Planet49 ruling that pre-checked boxes do not constitute valid consent, because there is no active action by the user. Any consent banner shipped with pre-selected categories or a hidden "reject" link violates applicable law at the legal level alone, regardless of the technical implementation. That applies not only to advertising trackers but to every script that processes data beyond strict technical necessity.

Technically, that means the initial state of every category except "necessary" must be off until the user actively agrees. A banner that only offers a confirmation button labeled "OK" while already treating all categories as active in the background is legally invalid, even if no checkbox is visibly ticked. Freely given consent also requires that rejecting is exactly as easy to reach as accepting, without a detour through a submenu or multiple clicks.

2. Granular consent: purposes, categories, and vendors

A legally compliant consent banner distinguishes at least between the categories "necessary," "statistics," "marketing," and "external media," with each category togglable independently of the others. A plain "accept all" surface without a granular selection option is not sufficient once more than one processing purpose is involved. Within each category, the concrete providers should also be listed, for example Google Analytics under statistics or the Meta Pixel under marketing, so the user can trace exactly who receives which data.

Technically, this is cleanly implemented with an Alpine.js component that holds the consent state per category in a reactive object and fires an event on every change, which script loaders can react to. It's important that the state doesn't only live in memory but is persisted consistently in a first-party cookie or LocalStorage with a timestamp and a version number for the consent configuration, so that later changes to the category list trigger a fresh prompt.


<!-- Alpine.js consent banner with per-category granular toggles -->
<div
    x-data="{
        open: !localStorage.getItem('consent_v1'),
        prefs: { necessary: true, statistics: false, marketing: false, external_media: false },
        save(acceptAll) {
            if (acceptAll) {
                this.prefs.statistics = true;
                this.prefs.marketing = true;
                this.prefs.external_media = true;
            }
            const payload = { ...this.prefs, ts: Date.now(), version: 1 };
            localStorage.setItem('consent_v1', JSON.stringify(payload));
            // Notify script loaders, do not just hide the banner
            window.dispatchEvent(new CustomEvent('consent:updated', { detail: payload }));
            this.open = false;
        }
    }"
    x-show="open"
    class="fixed inset-x-0 bottom-0 z-50 bg-white border-t border-slate-200 p-6"
>
    <fieldset class="grid grid-cols-2 gap-3 mb-4 text-sm">
        <label class="flex items-center gap-2 opacity-50">
            <input type="checkbox" checked disabled>
            Necessary
        </label>
        <label class="flex items-center gap-2">
            <input type="checkbox" x-model="prefs.statistics">
            Statistics (Google Analytics)
        </label>
        <label class="flex items-center gap-2">
            <input type="checkbox" x-model="prefs.marketing">
            Marketing (Meta Pixel)
        </label>
        <label class="flex items-center gap-2">
            <input type="checkbox" x-model="prefs.external_media">
            External media (YouTube)
        </label>
    </fieldset>

    <div class="flex gap-3">
        <button @click="save(false)">Save selection</button>
        <button @click="save(true)">Accept all</button>
        <button @click="prefs = { necessary: true, statistics: false, marketing: false, external_media: false }; save(false)">Reject all</button>
    </div>
</div>

3. The most common anti-pattern: hiding the banner while trackers still fire

By far the most common implementation mistake looks visually correct and is technically completely useless: the consent banner gets hidden via CSS once the user makes a selection, while every tracking script was already loaded normally in the <head> and has been sending data since the very first page load. To the user, the page looks compliant, but technically not a single script actually waited for consent. This pattern shows up surprisingly often in custom implementations that treat a consent banner as a pure UI feature with no connection to the actual script-loading logic.

The difference between a UI consent banner and a technically effective consent layer lies in whether loading and executing scripts is itself coupled to the consent state. A correctly implemented system prevents the browser from ever making a network request to a tracking endpoint before the corresponding category is active. That can only be achieved if scripts are never present as executable <script src="..."> tags in the initial HTML at all, but instead as disabled placeholders that only get activated once consent has been granted.

4. Technical script gating with type="text/plain"

The established pattern for actually blocking scripts works through the type value of the <script> tag. Browsers only execute scripts with a recognized JavaScript MIME type, such as text/javascript or no type attribute at all. If you set type="text/plain" and a data-consent-category attribute instead, the browser treats the script as plain text and does not execute it, even though it's syntactically fully present in the DOM. Only a small JavaScript loader, running after consent has been granted, scans the DOM for these disabled tags, clones them with the correct type, and re-inserts them, at which point the browser actually executes them.

This pattern has the decisive advantage of working independently of any tag manager and reliably blocking inline scripts too, not just externally loaded files. It's important to implement the cloning correctly: simply changing the type attribute on the existing node is not enough, because browsers do not retroactively execute an already-parsed script element. The node has to be newly created and inserted into the DOM.


// Scripts stay inert until consent is granted for their category
// <script type="text/plain" data-consent-category="statistics" data-src="/js/ga.js"></script>

function activateConsentedScripts(grantedCategories) {
  document.querySelectorAll('script[type="text/plain"][data-consent-category]').forEach((placeholder) => {
    const category = placeholder.getAttribute('data-consent-category');
    if (!grantedCategories.includes(category)) {
      return; // stays blocked, no network request is ever triggered
    }

    // Cloning is required: mutating type on the existing node does not execute it
    const activated = document.createElement('script');
    for (const attr of placeholder.attributes) {
      if (attr.name === 'type' || attr.name === 'data-consent-category') continue;
      activated.setAttribute(attr.name, attr.value);
    }

    const src = placeholder.getAttribute('data-src');
    if (src) {
      activated.src = src;
    } else {
      activated.textContent = placeholder.textContent;
    }

    placeholder.replaceWith(activated);
  });
}

window.addEventListener('consent:updated', (event) => {
  const granted = Object.entries(event.detail)
    .filter(([key, value]) => value === true)
    .map(([key]) => key);
  activateConsentedScripts(granted);
});

5. Tag manager consent gating in practice

If you use a tag manager like Google Tag Manager, don't leave blocking to the tag manager itself, but explicitly configure the consent check at the level of each individual tag. The container snippet itself can load early, since it does not send tracking data on its own, but every single tag inside the container must be coupled to its category via built-in consent settings, rather than relying on a purely visual banner control happening outside the tag manager.

In practice, that means explicitly configuring "additional consent checks" for every tag in the tag manager and using the built-in consent types like ad_storage, analytics_storage, and ad_user_data, instead of building custom triggers for consent that easily drift out of sync with the actual Consent Mode implementation. The big advantage of the built-in consent checks: they engage automatically once Consent Mode is correctly initialized, and prevent tag execution even when a developer accidentally forgets to set an individual trigger.

6. CMP integration patterns: IAB TCF v2.2 and custom solutions

For stores with advertising partners in the programmatic ad business, a consent management platform (CMP) compliant with the IAB Transparency and Consent Framework (TCF) v2.2 is often mandatory, because ad networks query the standardized consent string through the global __tcfapi() function before bidding. A TCF-compliant CMP encodes granular consent per purpose and vendor into a base64 string that gets exchanged across platforms between publisher, ad network, and demand-side platform. Custom-built consent solutions without TCF integration work fine for your own first-party scripts, but often fail at interoperability with third-party ad networks.

For purely first-party use cases without programmatic advertising, a lean custom CMP is often sufficient, as long as it satisfies the same core technical principles: granular categories, effective script blocking, timestamped consent logging, and an easily accessible way to withdraw consent. What matters more when choosing between a custom build and an off-the-shelf CMP is not the banner's look, but whether the integration actually couples every single script to the consent state at a technical level.

Google Consent Mode v2 is not a replacement for technical script blocking, but an additional signaling protocol that tells Google tags like Google Ads and Google Analytics what consent state a user has set. Since March 2024, Consent Mode v2 has been mandatory for advertisers in the European Economic Area once Google tags process advertising data. Central to this are two additional parameters, ad_user_data and ad_personalization, which must be set alongside the existing ad_storage and analytics_storage.

The correct implementation pattern consists of two calls: a gtag('consent', 'default', ...) call sets all consent types to denied before any Google tag loads, so Google tags run in the so-called cookieless ping mode and only send anonymized modeling data. After a user decision, a gtag('consent', 'update', ...) call updates the relevant values to granted. Important: Consent Mode does not replace blocking the scripts themselves; it only controls the behavior of already-loaded Google tags, which is why both mechanisms must be used together.


// Load BEFORE any Google tag (gtag.js, GTM container, Ads, Analytics)
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }

// Default: everything denied until the user decides
gtag('consent', 'default', {
  ad_storage: 'denied',
  analytics_storage: 'denied',
  ad_user_data: 'denied',
  ad_personalization: 'denied',
  wait_for_update: 500 // ms to wait for the banner before firing pings
});

// After the banner emits a decision, update the granted categories
window.addEventListener('consent:updated', (event) => {
  gtag('consent', 'update', {
    ad_storage: event.detail.marketing ? 'granted' : 'denied',
    analytics_storage: event.detail.statistics ? 'granted' : 'denied',
    ad_user_data: event.detail.marketing ? 'granted' : 'denied',
    ad_personalization: event.detail.marketing ? 'granted' : 'denied'
  });
});

8. Magento- and Hyvä-specific cookie handling

In Magento 2, third-party scripts are usually injected via layout XML in Magento_Theme::page/js/*.phtml templates or through a <head> block, often directly from extensions for tracking, chat widgets, or marketing tools. Every one of these injection points needs to be individually checked for whether it respects the script-gating pattern or lands as a classic, immediately loading <script src> in the head. Hyvä themes have the advantage of a lean, easily surveyable layout tree, in which script injection points are noticeably easier to locate than in classic Magento themes with Knockout.js components and RequireJS modules.

In practice, it pays off to build a dedicated module with a central block for all consent-gated scripts, wired in via layout XML into default.xml, which internally renders the disabled type="text/plain" tags with their respective categories. Magento's own cookie consent module (Magento_Cookie) only covers a simple yes/no confirmation and satisfies neither granularity nor technical blocking, which is why it should be replaced by a dedicated CMP integration for production stores.


<!-- app/design/frontend/Mironsoft/default/Magento_Theme/layout/default.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <!-- Remove any module block that injects a synchronous tracking <script src> -->
        <referenceBlock name="head.additional" remove="true"/>

        <referenceContainer name="before.body.end">
            <!-- Central consent-gated script block, renders type="text/plain" placeholders -->
            <block class="Mironsoft\CookieConsent\Block\ConsentScripts"
                   name="mironsoft.consent.scripts"
                   template="Mironsoft_CookieConsent::consent-scripts.phtml"/>

            <block class="Mironsoft\CookieConsent\Block\ConsentBanner"
                   name="mironsoft.consent.banner"
                   template="Mironsoft_CookieConsent::banner.phtml"/>
        </referenceContainer>
    </body>
</page>

9. Technical auditing: verifying consent actually blocks

The most reliable way to check a consent implementation is the network tab in the browser devtools, using a fully fresh browser profile with no existing cookies. Right after the first page load, before any interaction with the banner, only requests to strictly necessary endpoints should be visible. Any request to google-analytics.com, doubleclick.net, facebook.com/tr, or comparable tracking domains before active consent is unambiguous proof that the implementation does not work, no matter how the banner looks.

For repeatable checks, automated consent scanners or a custom headless-browser script with Playwright work well, logging network requests before and after each consent category and comparing them against the expected category. Such scans can be integrated into the CI pipeline and automatically raise an alert if a deployment introduces new, unreviewed scripts.


#!/usr/bin/env bash
# audit-consent.sh - fail CI if trackers fire before consent
set -euo pipefail

URL="${1:?Usage: audit-consent.sh <url>}"
TRACKER_PATTERN='google-analytics\.com|doubleclick\.net|facebook\.com/tr|hotjar\.com'

echo "[INFO] Recording network requests on a clean profile before any interaction..."
node scripts/record-requests.mjs "$URL" --no-cookies --no-interaction > /tmp/pre-consent.log

if grep -qE "$TRACKER_PATTERN" /tmp/pre-consent.log; then
  echo "[FAIL] Tracking requests fired before consent was granted:" >&2
  grep -E "$TRACKER_PATTERN" /tmp/pre-consent.log >&2
  exit 1
fi

echo "[OK] No tracking requests detected before consent."
Check point Non-compliant Correctly implemented Impact
Script injection <script src="ga.js"> directly in head type="text/plain" + consent callback Tracker does not fire before consent
Checkbox state All categories pre-checked All categories off by default Opt-in instead of opt-out
"Reject all" Missing or buried in a submenu Equally visible as "Accept" Genuine freedom of choice
Consent storage Frontend state only, no log Timestamped, versioned, provable Provable on audit
Google tags Fire regardless of consent state Consent Mode v2 with default denied No legal violation, no silent breaches

The table shows the difference between a purely visual and a technically effective consent implementation. Consistently applying all five check points results in a consent system that holds up both in court and in the network tab.

Mironsoft

Cookie consent, privacy, and technical GDPR audits for Magento stores

Cookie consent that actually blocks?

We audit your existing consent implementation in the network tab, expose trackers firing silently, and build real script gating with Google Consent Mode v2 and CMP integration for your Magento or Hyvä store.

Consent audit

Network tab analysis and automated consent scans before and after consent

Script gating

Wiring up the type="text/plain" pattern and tag manager consent checks correctly

CMP integration

Google Consent Mode v2, IAB TCF v2.2, and Magento/Hyvä layout XML

10. Summary

Legally compliant cookie consent is more than a nice-looking banner: it requires opt-in instead of pre-checked boxes, granular categories instead of a single all-or-nothing surface, and above all real technical blocking instead of a purely visual UI element. The type="text/plain" pattern prevents scripts from executing before the matching category is active, while tag manager consent checks enforce the same principle at the level of individual tags. Google Consent Mode v2 adds a signaling protocol for Google's own tags on top of that, but it does not replace actually blocking the scripts.

The only reliable proof that an implementation works is a technical check: a fresh browser profile, the network tab, and ideally an automated scan in the CI pipeline that ensures before every deployment that no new trackers fire unnoticed before consent. Running this check regularly closes the gap between a banner that looks compliant and an implementation that actually is.

Implementing Cookie Consent Cleanly, Technically - The Essentials at a Glance

Opt-in instead of opt-out

Pre-checked boxes have been invalid since the Planet49 ruling. Every category except "necessary" starts off.

Granularity

Purpose- and vendor-level control instead of a single all-or-nothing surface for every category.

Real script gating

type="text/plain" plus a consent callback instead of pure banner CSS that lets scripts keep running.

Consent Mode v2 + audit

default denied, update granted, plus regular network tab verification before every release.

11. FAQ: Implementing Cookie Consent Cleanly, Technically

1Why isn't an accept-all button without granular selection sufficient?
With more than one processing purpose, GDPR-compliant consent requires separate agreement per purpose. A single surface forces all-or-nothing, which is not specific consent.
2What is the difference between opt-in and opt-out?
Opt-in activates a category only after active agreement. Pre-checked boxes (opt-out) are not valid consent after the CJEU's Planet49 ruling.
3Why isn't hiding the banner an effective block?
Already-loaded scripts keep running regardless of the visible banner state. Only a technical coupling of script execution to the consent state prevents that.
4How does the type=text/plain pattern work?
Browsers only execute scripts with a known JS MIME type. type=text/plain makes the tag inert. A loader only clones it into the DOM with the correct type after consent.
5What is Google Consent Mode v2?
Adds ad_user_data and ad_personalization to ad_storage and analytics_storage. Mandatory since March 2024 for EEA advertisers once Google tags process ad data.
6Does Consent Mode replace technically blocking scripts?
No. It only controls the behavior of already-loaded Google tags and must be used together with real script gating.
7When is an IAB TCF-compliant CMP necessary?
Once programmatic ad networks are integrated that query the consent string via __tcfapi. Pure first-party scripts often only need a custom CMP.
8How do I verify my banner actually blocks?
Fresh browser profile, open the network tab, load the page without interacting. Any tracking request before consent shows a broken implementation.
9Does Magento's built-in cookie module cover GDPR?
Magento_Cookie only offers a yes/no confirmation without granularity or technical blocking. Production stores need a dedicated CMP integration.
10Why should consent checking be part of the CI pipeline?
New features can unnoticeably introduce a synchronously loading tracking script. An automated scan catches such regressions before the live deployment.