Cookie Consent Module in the Hyvä Theme: GDPR-Compliant and CSP-Safe
AI generated
Hyvä
phtml
Hyvä · GDPR · CSP · Alpine.js
Cookie Consent Module in the Hyvä Theme
Building It GDPR-Compliant and CSP-Safe

A cookie banner that enables Google Tag Manager through an inline script immediately violates the Content-Security-Policy in a Hyvä theme and gets silently blocked by the browser. This article shows how to build a cookie consent module in the Hyvä theme that satisfies both GDPR requirements and CSP restrictions at once, without nonce violations and without loading third-party scripts before consent is given.

14 min read Alpine.js · Magento_Csp · Consent Mode v2 Magento 2.4.8 · Hyvä CSP

1. GDPR and CSP: Two Requirements, One Solution

Anyone tasked with building a cookie consent module in the Hyvä theme runs into two requirements that seem to contradict each other at first glance. GDPR requires that analytics and marketing scripts only load after explicit user consent, meaning the banner itself must actively control which code even executes. At the same time, Hyvä's own CSP module, built on top of Magento_Csp, blocks by default every inline script without a valid nonce and every script tag from a domain that has not been whitelisted. A classic cookie banner, the kind described in countless Luma-theme tutorials, injects the GTM container via document.write or a dynamically created <script> tag after the user clicks "Accept". That is exactly what a correctly configured Hyvä shop's CSP prevents.

The solution is not to loosen the CSP or allow unsafe-inline, because doing so would defeat the very protection that sets Hyvä apart from classic Magento themes. A properly built Hyvä cookie consent system instead works with the framework's own tools: an Alpine.js store for reactive state management, explicit entries in csp_whitelist.xml for allowed third-party domains, and the project-mandated call to $hyvaCsp->registerInlineScript() after every inline block. Only the combination of these three mechanisms produces a GDPR-compliant cookie consent module that compromises neither the security nor the legal compliance of the shop.

The central building block of a cookie consent module in the Hyvä theme is a globally registered Alpine store, not a local x-data component per banner instance. The reason: consent state must be available shop-wide, in the footer link to the preferences modal just as much as in the banner itself, and in every component that conditionally loads a third-party service. Alpine.store('consent', {...}) is defined once, centrally, and is afterward accessible from every template through $store.consent, without duplicating state or synchronizing it through custom events.

The store persists its state in localStorage so consent survives across page loads, and additionally mirrors that same state into a cookie so PHP can read it server-side, more on that in section six. The store's init() method loads the saved state on Alpine's first tick, but falls back to the GDPR-compliant default when nothing is stored yet: everything except the essential category is denied initially. This structure makes the Hyvä cookie consent module reactive, every component using x-show="$store.consent.granted('analytics')" reacts instantly to changes, with no page reload required.


// Alpine.js consent store, registered globally before Alpine.start()
// File: web/js/consent-store.js (loaded via requirejs-config.js, no inline script needed)
document.addEventListener('alpine:init', () => {
  Alpine.store('consent', {
    // Reactive state per category
    categories: {
      essential: true,      // always true, cannot be disabled
      functional: false,
      analytics: false,
      marketing: false,
    },
    interacted: false,

    init() {
      const saved = localStorage.getItem('mironsoft_consent');
      if (saved) {
        const parsed = JSON.parse(saved);
        this.categories = { ...this.categories, ...parsed.categories };
        this.interacted = true;
      }
    },

    granted(category) {
      return this.categories[category] === true;
    },

    acceptAll() {
      Object.keys(this.categories).forEach((key) => (this.categories[key] = true));
      this.persist();
    },

    rejectAll() {
      Object.keys(this.categories).forEach((key) => {
        if (key !== 'essential') this.categories[key] = false;
      });
      this.persist();
    },

    save(selection) {
      this.categories = { ...this.categories, ...selection };
      this.persist();
    },

    persist() {
      this.interacted = true;
      localStorage.setItem('mironsoft_consent', JSON.stringify({ categories: this.categories }));
      // Mirror to a first-party cookie so PHP can read consent server-side
      document.cookie = `mironsoft_consent=${encodeURIComponent(JSON.stringify(this.categories))}; path=/; max-age=31536000; SameSite=Lax`;
      // Dispatch a DOM event so Consent Mode v2 update signals can react
      window.dispatchEvent(new CustomEvent('consent:changed', { detail: this.categories }));
    },
  });
});

3. csp_whitelist.xml for Third-Party Scripts

Even if the Alpine store correctly decides whether Google Tag Manager may load, that decision is worthless if the CSP still blocks the request to googletagmanager.com. Magento's Magento_Csp module works with what it calls sender policies per domain and directive type: script-src, connect-src, frame-src, img-src. Without a matching entry, the browser console reports a CSP violation and the tag manager container simply never loads, regardless of how correctly the consent logic in the frontend is implemented. For a working cookie consent module in the Hyvä theme, the whitelist is therefore not an afterthought, it is a hard prerequisite.

The whitelist is added as a standalone etc/csp_whitelist.xml inside the module and defines, for each domain, under which policy it is allowed. For Google Tag Manager and Consent Mode v2 you typically need entries for script-src (googletagmanager.com), connect-src (region1.google-analytics.com, google-analytics.com) and img-src for pixel tracking. Important: the whitelist only permits the domain to be loaded, it does not replace the consent check. The actual gate, whether the script is inserted into the DOM at all, remains the responsibility of the Alpine store and the conditional layout blocks.


<!-- File: app/code/Mironsoft/CookieConsent/etc/csp_whitelist.xml -->
<csp_whitelist xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
               xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Csp:etc/csp_whitelist.xsd">
    <policies>
        <policy id="script-src">
            <values>
                <value id="gtm" type="host">https://www.googletagmanager.com</value>
                <value id="gtm-cdn" type="host">https://*.googletagmanager.com</value>
            </values>
        </policy>
        <policy id="connect-src">
            <values>
                <value id="ga4-collect" type="host">https://*.google-analytics.com</value>
                <value id="ga4-region" type="host">https://region1.google-analytics.com</value>
            </values>
        </policy>
        <policy id="img-src">
            <values>
                <value id="gtm-pixel" type="host">https://www.googletagmanager.com</value>
                <value id="ga-pixel" type="host">https://*.google-analytics.com</value>
            </values>
        </policy>
        <policy id="frame-src">
            <values>
                <value id="gtm-preview" type="host">https://www.googletagmanager.com</value>
            </values>
        </policy>
    </policies>
</csp_whitelist>

4. Using registerInlineScript() Correctly

Even with a correct whitelist, a problem remains: the banner itself, the Consent Mode default snippet and the Alpine initialization often contain inline scripts, and those are exactly what Hyvä's CSP module blocks by default, because they carry no valid nonce. Project convention for every Hyvä theme at Mironsoft is therefore: after every inline <script> block in a phtml template, a call to $hyvaCsp->registerInlineScript() must follow. This method computes a hash or nonce for that exact script content and registers it with the CSP middleware, so the browser executes the script even though it sits inline in the HTML.

For a GDPR-compliant cookie consent module this affects several spots: the default consent snippet for Google Consent Mode v2, which must sit before any other script in the head, the Alpine store initialization, if it is not loaded from an external file, and possibly a small bootstrap script that shows the banner on the first visit. Without the registerInlineScript() call, the code often still works in developer mode, because CSP reports there are only logged, but in production with enforced CSP the execution then silently fails, a classic Hyvä debugging trap.


<?php
/** @var \Hyva\Theme\Model\ViewModelRegistry $viewModels */
/** @var \Hyva\Theme\ViewModel\HyvaCsp $hyvaCsp */
$hyvaCsp = $viewModels->require(\Hyva\Theme\ViewModel\HyvaCsp::class);
?>
<!-- File: templates/consent/banner.phtml -->
<div
    x-data
    x-show="!$store.consent.interacted"
    x-cloak
    class="fixed inset-x-0 bottom-0 z-50 bg-slate-900 text-white p-6 shadow-2xl"
    role="dialog"
    aria-label="Cookie consent"
>
    <p class="text-sm mb-4">
        We use cookies for essential functionality and, optionally, for
        analytics and marketing. Details in the preferences modal.
    </p>
    <div class="flex gap-3">
        <button type="button" x-on:click="$store.consent.acceptAll()" class="bg-orange-600 px-4 py-2 rounded-lg text-sm font-bold">
            Accept all
        </button>
        <button type="button" x-on:click="$store.consent.rejectAll()" class="border border-white/40 px-4 py-2 rounded-lg text-sm">
            Essential only
        </button>
        <button type="button" x-on:click="$store.open('preferences')" class="text-sm underline">
            Settings
        </button>
    </div>
</div>

<script>
    // Bootstrap: show banner only if the user has not interacted yet
    window.addEventListener('alpine:init', () => {
        if (!localStorage.getItem('mironsoft_consent')) {
            document.dispatchEvent(new CustomEvent('consent:show-banner'));
        }
    });
</script>
<?php /* Mandatory after every inline script block in a Hyva theme */ ?>
<?= $hyvaCsp->registerInlineScript() ?>

Since Google Consent Mode v2 became mandatory for advertisers in the European Economic Area, simply loading GTM after consent is no longer enough. Google additionally expects two consent signals, ad_user_data and ad_personalization, that must be set to "denied" before any user interaction at all, the so-called default signal. Without this default signal, Google noticeably throttles data quality in GA4 and Ads, even if the user consents later. A complete cookie consent module in the Hyvä theme must respect this timing precisely: default signal before the GTM container tag, update signal only after explicit interaction.

Technically that means two separate gtag('consent', ...) calls. The default call sits as its own block, registered through registerInlineScript(), at the very top of the head, before the actual GTM snippet, and sets all advertising-relevant categories to "denied". As soon as the user makes a choice in the banner or the preferences modal, the Alpine store fires the consent:changed event, a listener translates the categories into gtag('consent', 'update', {...}) and Google adjusts tag delivery in real time, with no page reload. This update-signal logic is the part many cookie plugins for classic themes simply do not implement, because they were built before Consent Mode v2 existed.


{
  "_comment_default": "Fires before GTM container, all ad signals denied by default",
  "default": {
    "ad_storage": "denied",
    "ad_user_data": "denied",
    "ad_personalization": "denied",
    "analytics_storage": "denied",
    "functionality_storage": "denied",
    "personalization_storage": "denied",
    "security_storage": "granted",
    "wait_for_update": 500
  },
  "_comment_update": "Fired from the consent:changed listener after user interaction",
  "update_on_accept_all": {
    "ad_storage": "granted",
    "ad_user_data": "granted",
    "ad_personalization": "granted",
    "analytics_storage": "granted",
    "functionality_storage": "granted",
    "personalization_storage": "granted"
  },
  "update_on_reject_all": {
    "ad_storage": "denied",
    "ad_user_data": "denied",
    "ad_personalization": "denied",
    "analytics_storage": "denied",
    "functionality_storage": "denied",
    "personalization_storage": "denied"
  }
}

6. Reading Consent State Server-Side

The Alpine store and localStorage solve client-side gating, but not every decision may be made only in the browser. When a layout XML block should suppress an entire GTM container, including its noscript fallback, or when a server-rendered branch needs to deliver different content depending on consent status, PHP needs access to the current state before the first byte of HTML ever leaves the backend. That is why the Alpine store mirrors its state into a first-party cookie, and a ViewModel reads that cookie server-side.

Such a ViewModel implements ArgumentInterface and, in PHP 8.4, uses constructor property promotion to inject CookieManagerInterface. The method hasConsent(string $category) decodes the JSON cookie and returns a clean boolean that both phtml templates and layout conditions can use equally. This makes the cookie consent module in the Hyvä theme not only reactive on the client, but also consistent on the server, an important difference from purely JavaScript-based solutions, which remain entirely ineffective with JavaScript disabled or on the very first server response.


<?php

declare(strict_types=1);

namespace Mironsoft\CookieConsent\ViewModel;

use Hyva\Theme\Model\ViewModelRegistry;
use Magento\Framework\Stdlib\CookieManagerInterface;
use Magento\Framework\View\Element\Block\ArgumentInterface;

/**
 * Reads the consent cookie mirrored by the Alpine.js consent store
 * to allow server-side gating of layout blocks and templates.
 */
final class ConsentStatus implements ArgumentInterface
{
    private const COOKIE_NAME = 'mironsoft_consent';

    /**
     * @param CookieManagerInterface $cookieManager Reads first-party cookies from the request
     */
    public function __construct(
        private readonly CookieManagerInterface $cookieManager
    ) {
    }

    /**
     * Checks whether the visitor has granted consent for a given category.
     *
     * @param string $category One of essential, functional, analytics, marketing
     * @return bool True if the category is granted, false for missing or denied consent
     */
    public function hasConsent(string $category): bool
    {
        if ($category === 'essential') {
            return true;
        }

        $raw = $this->cookieManager->getCookie(self::COOKIE_NAME);
        if ($raw === null) {
            return false;
        }

        /** @var array<string, bool>|null $decoded */
        $decoded = json_decode((string) $raw, true);
        if (!is_array($decoded)) {
            return false;
        }

        return $decoded[$category] ?? false;
    }
}

7. Categories: Essential, Functional, Analytics, Marketing

A legally sound GDPR-compliant cookie consent module distinguishes at least four categories, because GDPR and the corresponding ePrivacy rules demand different legal bases for different purposes. Essential cookies, session ID, CSRF token, cart persistence, are technically necessary and may be set without consent, so their toggle in the banner is permanently fixed to true in the Alpine store and cannot be turned off. Functional cookies cover convenience features, such as a wishlist remembered across multiple visits, without which the shop still fundamentally works.

Analytics cookies, GA4, Hotjar, Microsoft Clarity, require explicit consent because they evaluate user behavior in a personally identifiable way. Marketing cookies, Google Ads remarketing, Meta Pixel, LinkedIn Insight Tag, carry the strictest requirements, since they often generate identifiers shared with third parties. Each of these four categories gets its own boolean flag in the Alpine store and its own conditional block in the layout XML, so a Hyvä cookie consent system can granularly control which script even reaches the DOM under which condition, instead of deciding "all or nothing" across the board.

8. Preferences Modal With Alpine x-transition

GDPR requires not only an initial consent decision, but also the ongoing ability to revoke or adjust it, without forcing the user to clear the browser cache or contact support. A first-visit-only banner without permanent access to the settings is therefore not a complete cookie consent module in the Hyvä theme, even if it is technically CSP-compliant. A footer link "Cookie settings" must open the same preferences modal that is also reachable through the banner's "Settings" link on the first visit.

The modal itself uses Alpine's x-show combined with x-transition for a smooth fade in and out, bound to an additional store flag such as $store.consent.preferencesOpen. For each of the four categories, the modal renders its own toggle switch bound directly to $store.consent.categories.analytics and so on, essential stays disabled and is shown with an explanatory note. A "Save" button calls $store.consent.save(selection), which triggers both persistence and the Consent Mode update event, so changes take effect immediately, with no page reload required.

9. Consent Patterns Compared

The differences between a naive cookie banner implementation, the kind copied from Luma or WordPress tutorials, and a correctly built cookie consent module in the Hyvä theme show up most clearly when comparing the individual building blocks side by side.

Dimension Naive implementation Hyvä cookie consent (correct) Benefit
Script inclusion Inline <script> without a nonce $hyvaCsp->registerInlineScript() Runs under strict CSP, no report-only workaround needed
Third-party domains No whitelist, script-src blocks it csp_whitelist.xml entries GTM/GA4 load reliably after consent
Consent storage Only document.cookie, not reactive Alpine store + localStorage + cookie mirror Reactive UI and readable server-side
Google signals No Consent Mode, tags fire instantly Consent Mode v2, default denied + update GDPR-compliant, no data quality losses
Revocability One-time banner, no revocation Preferences modal, always in the footer Satisfies the GDPR revocation requirement

Taken together, the comparison shows that no single measure is sufficient on its own. Only the combination of CSP-compliant script registration, an explicit domain whitelist, a reactive consent store, correctly timed Google signals and permanent revocability produces a GDPR-compliant cookie consent module that protects against both legal warnings and silent CSP blocks.

10. Summary

A cookie consent module in the Hyvä theme is not a pure frontend feature, it is an interplay of CSP configuration, reactive state management and server-side readability. The Alpine.js store handles the reactive UI logic and persistence in localStorage, csp_whitelist.xml whitelists exactly the domains that may actually load after consent, and registerInlineScript() ensures that every necessary inline block, from the Consent Mode default to the banner bootstrap, actually executes under the strict CSP at all.

Anyone who assembles these building blocks correctly ends up with a GDPR-compliant cookie consent module that serves Google Consent Mode v2 correctly, lets users change their preferences at any time, and violates not a single CSP rule in the process. That combination is exactly what distinguishes a well-thought-out, Hyvä-native system from generic cookie plugins built for classic, CSP-free themes, which simply do not work in a strictly locked-down Hyvä shop.

Cookie Consent Module in the Hyvä Theme, at a Glance

Alpine.js consent store

Global store with essential, functional, analytics and marketing categories, persisted in localStorage and mirrored into a cookie.

csp_whitelist.xml

Explicitly whitelists GTM and GA4 domains for script-src, connect-src and img-src, without allowing unsafe-inline.

registerInlineScript()

Mandatory after every inline script block, so the Hyvä CSP executes it despite the strict nonce policy.

Consent Mode v2

Default signal denied before the GTM container, update signal on every consent change, with no page reload.

11. FAQ: Cookie Consent Module in the Hyvä Theme

1What is a cookie consent module in the Hyvä theme?
An Alpine.js-based system that collects consent and reactively controls which third-party scripts load, without violating Hyvä's strict CSP.
2Why does the CSP block classic cookie banners?
Inline scripts without a nonce and domains without a whitelist entry are blocked by Magento_Csp by default, regardless of the consent logic.
3What does registerInlineScript() do exactly?
Registers the preceding inline block with the CSP middleware, generating a hash or nonce so the browser executes it despite the strict policy.
4How does an Alpine.js consent store work?
A global Alpine.store holds reactive state, persists it in localStorage, mirrors it into a cookie. Components bind directly to the store via x-show.
5What belongs in csp_whitelist.xml for GTM?
script-src for googletagmanager.com, connect-src for google-analytics.com, img-src for pixel tracking. Without these entries the request stays blocked.
6What is Google Consent Mode v2?
Two additional signals, ad_user_data and ad_personalization, denied by default. Without this signal Google throttles data quality in GA4 and Ads.
7How do I read consent server-side in PHP?
A ViewModel with CookieManagerInterface reads the mirrored cookie, decodes JSON, and exposes hasConsent(string $category) for templates.
8What categories does a compliant module need?
At least essential, functional, analytics and marketing, each with its own layout block or script gate.
9How do I build a preferences modal?
A modal using x-show and x-transition, one toggle per category, reachable from the footer and the banner, so consent stays changeable at any time.
10Can I use an off-the-shelf plugin?
Most plugins inject inline scripts without a nonce and fail against the strict Hyvä CSP, a purpose-built module accounts for exactly those rules.

Mironsoft

Hyvä development, CSP compliance and GDPR-compliant frontend architecture

Cookie consent module in the Hyvä theme, without CSP violations?

We build your Hyvä cookie consent module from the ground up, CSP-safe: Alpine store, csp_whitelist.xml, registerInlineScript() and Google Consent Mode v2, tested cleanly against your production CSP configuration.

CSP audit

Checking existing cookie banners for inline script and whitelist violations

Consent store

Alpine.js consent store with categories, preferences modal and cookie mirror

Consent Mode v2

Implementing Google Consent Mode v2 default and update signals correctly