Back to blog

Your Analytics Script Is the Hole in Your Content-Security-Policy

A strict CSP closes XSS. A third-party analytics tag reopens it. Here is why host allowlists and missing SRI undermine your policy — and what a first-party tracker fixes.

A Content-Security-Policy is the single most effective defense against cross-site scripting. The moment you add a third-party analytics tag, you usually have to punch a hole in it. That hole is where most real-world CSP bypasses live.

The reason is structural, not accidental. Surveillance-heavy analytics scripts are designed to load more code at runtime, from domains you do not control. A strict CSP exists to forbid exactly that.

Host allowlists are the weak way to trust a script

The old way to allow an analytics vendor was a host allowlist:

Content-Security-Policy: script-src 'self' https://www.google-analytics.com https://www.googletagmanager.com;

OWASP no longer recommends this pattern. Allowlists are easily bypassed: any allowed origin that hosts a JSONP endpoint, an open redirect, or user-uploaded scripts becomes an XSS vector. Tag managers make it worse, because they exist precisely to inject further scripts from arbitrary origins.

The current leading practice is a strict CSP built on a per-response nonce plus strict-dynamic:

Content-Security-Policy: script-src 'nonce-r4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'none';

With strict-dynamic, the browser trusts scripts loaded by an already-trusted script — which is exactly the behavior a tag manager depends on. That is convenient for the vendor and dangerous for you: one compromised tag now has implicit permission to load anything.

SRI does not work on the scripts that need it most

Subresource Integrity lets the browser verify a fetched file against a cryptographic hash before executing it:

<script
  src="https://cdn.example.com/tracker.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  crossorigin="anonymous"></script>

SRI supports sha256, sha384, and sha512, and the browser refuses the resource on any mismatch. It is a clean defense against a compromised CDN — for static, version-pinned files.

It collapses for analytics. Vendor tags are deliberately mutable: the file at the same URL changes whenever the vendor ships features, so a pinned hash would break collection on the next deploy. In practice teams drop SRI on exactly the scripts with the broadest reach into the page. The October 2025 recalibration of supply-chain risk rated unsafe SRI implementation high severity for this reason.

Trusted Types raise the floor in February 2026

The DOM-XSS half of the problem now has a browser-level answer. As of Baseline February 2026, Trusted Types are available across current browsers. You enable enforcement with:

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default;

After that, DOM sinks like Element.innerHTML reject raw strings and accept only values minted by a registered policy:

const policy = trustedTypes.createPolicy("default", {
  createHTML: (input) => DOMPurify.sanitize(input),
});
el.innerHTML = policy.createHTML(userInput); // ok
el.innerHTML = userInput;                     // throws TypeError

This is genuinely strong. It is also the kind of rule that legacy analytics and tag-manager code routinely violates, because those scripts write to innerHTML and inject <script> nodes as plain strings. Turning on Trusted Types often means turning off your analytics vendor first.

A first-party tracker fits a strict policy instead of fighting it

The conflict disappears when the analytics script is small, self-contained, and served from your own origin. A tracker that loads no further code needs neither strict-dynamic nor a vendor host on your allowlist:

Content-Security-Policy: script-src 'self'; object-src 'none'; base-uri 'none'; require-trusted-types-for 'script';

That is it. No third-party origin, no nonce escalation, no exception carved for a tag manager.

Monoid's tracker is roughly 2 KB, has no dependencies, and never calls a DOM-XSS sink. It does not write innerHTML, does not inject scripts, and does not read or set cookies or storage. It sends one keepalive request per pageview to /collect and otherwise stays idle:

fetch('/collect', {
  method: 'POST',
  body: JSON.stringify({ site_id, path, referrer, screen_w }),
  keepalive: true,
});

Because the file is static, you can apply SRI to it without it ever breaking — a pinned hash stays valid until you choose to update the script. And because identity is a one-way daily hash, SHA-256(IP | UA | SALT_SECRET | YYYY-MM-DD), there is no cross-site profiling that a relaxed CSP could even leak.

The pattern generalizes past analytics: every script you remove from your allowlist is a class of XSS bypass you remove with it. The most secure third-party script is the one you do not load.

Sources

Comments

Loading comments…