# The blocking layer
Authoritative basis: Article 5(3) Directive 2002/58/EC; EDPB Guidelines 2/2023 on the technical scope of Art. 5(3) ePrivacy Directive, Version 2.0, adopted 7 October 2024 (para 36: storage occurs when a party instructs software on the terminal equipment; para 50: distributing a tracking pixel or link is already storage through client-side caching; para 51: collecting the identifier back is "gaining of access"). Status as at 2026-08-05.
Consequence: the unit of control is **the request**, not the cookie. Everything below exists to stop requests.
## 1. Type swapping — for markup you control
Give a blocked script a non-executable `type` and carry the real one in a data attribute. Browsers do not execute unknown script types and do not fetch their `src`.
```html
```
The unblocker rebuilds the element. Attributes must be copied; you cannot flip `type` in place on a script that has already been parsed.
```js
// consent-unblock.js — first-party, loaded normally, no dependencies
export function unblock(category) {
const nodes = document.querySelectorAll(
`script[type="text/plain"][data-consent-category="${category}"]`
);
for (const old of nodes) {
const s = document.createElement('script');
for (const { name, value } of old.attributes) {
if (name === 'type' || name === 'data-src' || name === 'data-consent-category') continue;
s.setAttribute(name, value);
}
if (old.dataset.src) s.src = old.dataset.src;
else s.textContent = old.textContent;
s.async = old.hasAttribute('async');
old.parentNode.replaceChild(s, old);
}
}
```
Order matters: replaced scripts execute in DOM order for `src` scripts only if you preserve `async`/`defer` semantics deliberately. If a vendor requires a strict sequence, chain on `load` instead of replacing in a loop.
## 2. Dynamic injection — the safer default
For anything you can call from JS, do not ship the tag in markup at all. Inject after the signal.
```js
const loaded = new Set();
export function loadScript(src, { attrs = {}, async = true } = {}) {
if (loaded.has(src)) return Promise.resolve();
loaded.add(src);
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.async = async;
for (const [k, v] of Object.entries(attrs)) s.setAttribute(k, v);
s.onload = () => resolve();
s.onerror = () => { loaded.delete(src); reject(new Error(`failed: ${src}`)); };
document.head.appendChild(s);
});
}
```
Wire it to a single consent store so every consumer reads one source of truth:
```js
const KEY = 'consent.v3'; // bump with the banner version
const listeners = new Set();
let state = null;
export function getConsent() {
if (state) return state;
try { state = JSON.parse(localStorage.getItem(KEY)); } catch { state = null; }
return state;
}
export function setConsent(next) {
state = next;
localStorage.setItem(KEY, JSON.stringify(next));
for (const fn of listeners) fn(next);
}
export function onConsent(fn) { listeners.add(fn); const s = getConsent(); if (s) fn(s); }
// consumer
onConsent(c => {
if (c.categories.analytics) loadScript('https://cdn.example-analytics.com/tracker.js');
});
```
The consent store itself is strictly necessary within the meaning of Article 5(3) ePD — it retains a preference expressed by the user (Cookie Banner Taskforce report, adopted 17 January 2023, para 30, citing WP29 Opinion 04/2012). Keep it first-party and do not gate it behind itself.
## 3. Content-Security-Policy — the backstop
CSP does not implement consent. It catches the tag someone adds in the tag manager on a Friday. Deploy a policy that omits every non-essential host, and widen it only when you also widen the gate.
```
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-[[NONCE]]';
connect-src 'self';
img-src 'self' data:;
font-src 'self';
frame-src 'none';
base-uri 'self';
object-src 'none';
report-uri /csp-report
```
Two properties make this useful: violations are reported with the blocked URI, and `connect-src 'self'` also stops `fetch`, XHR, WebSocket and `navigator.sendBeacon` — the channels a bundled SDK uses without ever adding a script tag. Run it in `Content-Security-Policy-Report-Only` first, read the reports, then enforce.
CSP cannot be your only control: a consenting user needs the third-party hosts allowed, and a policy cannot vary per user without server-side rendering of the header. Where consent is server-visible, emit two policies; where it is not, keep CSP tight and rely on the JS gate, using CSP reports purely as a leak detector.
## 4. Where consent leaks
| Leak | Why it fires early | Fix |
|---|---|---|
| ``, `rel="dns-prefetch">` | Opens DNS/TCP/TLS to the third party during parse, before any script decision | Remove; inject the hint together with the script after consent |
| `` | Downloads the resource immediately | Remove |
| `` pixel, `` | A pixel is a request; Guidelines 2/2023 §3.1 puts it squarely in scope | Swap `src` to `data-src`, restore after consent |
| CSS `@font-face { src: url(https://…) }`, `background-image: url(https://…)` | Fetched by the CSSOM, no JS involved | Self-host the asset |
| `