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.
Table of Contents
- 1. GDPR and CSP: Two Requirements, One Solution
- 2. Building the Alpine.js Consent Store
- 3. csp_whitelist.xml for Third-Party Scripts
- 4. Using registerInlineScript() Correctly
- 5. Implementing Google Consent Mode v2
- 6. Reading Consent State Server-Side
- 7. Categories: Essential, Functional, Analytics, Marketing
- 8. Preferences Modal With Alpine x-transition
- 9. Consent Patterns Compared
- 10. Summary
- 11. FAQ
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.
2. Building the Alpine.js Consent Store
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() ?>
5. Implementing Google Consent Mode v2
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?
2Why does the CSP block classic cookie banners?
3What does registerInlineScript() do exactly?
4How does an Alpine.js consent store work?
5What belongs in csp_whitelist.xml for GTM?
6What is Google Consent Mode v2?
7How do I read consent server-side in PHP?
8What categories does a compliant module need?
9How do I build a preferences modal?
10Can I use an off-the-shelf plugin?
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