I’ve seen this pattern a lot: a team wants privacy-friendly analytics, swaps out Google Analytics for Plausible, and suddenly their dashboards are empty because CSP is blocking the script or the event beacons.

The funny part is Plausible is usually one of the easier analytics tools to fit into a strict policy. The hard part is not Plausible itself. The hard part is cleaning up the old CSP assumptions that were built around GTM, GA, consent tools, and years of exceptions nobody wants to touch.

Here’s a real-world style case study based on that exact migration.

The setup

A content site wanted to replace Google Analytics with Plausible. Their app already had a pretty serious CSP. Not perfect, but serious enough that random third-party scripts didn’t just slide in unnoticed.

A real CSP header from HeaderTest looks like this:

content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-ZWM0ZmY0NjQtNTEyMi00YzM3LWFkMmUtZTY5MzU4Zjc1ZTVi' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com; frame-src 'self' https://consentcdn.cookiebot.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'

That’s not a Plausible policy. It’s a realistic “grown over time” production policy. You can tell exactly what happened:

  • Google Tag Manager was added
  • Google Analytics was added
  • Cookiebot was added
  • websocket/reporting endpoints were added
  • nobody wanted to break production, so old sources stayed forever

Then the team replaced GA with Plausible and expected this to “just work.”

It didn’t.

The first broken attempt

They dropped the standard Plausible snippet into the page:

<script defer data-domain="csp-guide.example" src="https://plausible.io/js/script.js"></script>

And then nothing showed up in Plausible.

The browser console gave them the answer immediately:

Refused to load the script 'https://plausible.io/js/script.js' because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-...' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com".

A second error followed after they tried to loosen script-src but forgot the beacon endpoint:

Refused to connect to 'https://plausible.io/api/event' because it violates the following Content Security Policy directive: "connect-src 'self' ...".

That’s the classic two-part failure with analytics under CSP:

  1. script-src blocks the analytics JavaScript
  2. connect-src blocks the event submission

If you only fix one, tracking still fails.

Why the existing CSP was trickier than it looked

The big gotcha here was strict-dynamic.

This part matters:

script-src 'self' 'nonce-...' 'strict-dynamic' https://www.googletagmanager.com ...

When you use a nonce together with strict-dynamic, modern browsers prioritize trust based on the nonce rather than the host allowlist. That’s usually a good thing. I’m a fan of nonce-based CSP because it scales better than maintaining giant source lists.

But it also means you need to think clearly about how your Plausible script gets onto the page.

Case 1: external script tag directly in HTML

If your HTML contains:

<script defer data-domain="csp-guide.example" src="https://plausible.io/js/script.js"></script>

then the external script needs to be allowed by CSP. If your policy relies on nonces, you may want to nonce the tag too:

<script
  nonce="{{ .CSPNonce }}"
  defer
  data-domain="csp-guide.example"
  src="https://plausible.io/js/script.js"></script>

Case 2: a trusted nonced bootstrap script injects Plausible

If you have a nonced inline loader that creates the script element dynamically, strict-dynamic can allow the descendant script without host-based allowlisting in modern browsers.

That said, most teams I work with still explicitly list Plausible in script-src and connect-src because it makes the policy easier to read and plays better with older browser behavior. Clarity wins.

Before: the old policy

Here’s the simplified “before” policy the team had around analytics:

Content-Security-Policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://api.example.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
  frame-src 'self' https://consentcdn.cookiebot.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

This worked for GA. It did nothing for Plausible.

After: the smallest working Plausible change

The minimal fix was to add Plausible to script-src and connect-src.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic' https://plausible.io;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://plausible.io;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

And the page script:

<script
  nonce="{{ .CSPNonce }}"
  defer
  data-domain="csp-guide.example"
  src="https://plausible.io/js/script.js"></script>

That was enough to restore event delivery.

After: the better cleanup version

The real improvement came when the team stopped treating this as “just add one more domain” and used the migration to remove dead analytics sources.

They had already removed GA and GTM from the app. The CSP just hadn’t caught up.

So the better final policy became:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic' https://plausible.io;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self' https://plausible.io;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  report-to csp-endpoint;
  report-uri https://csp-report.example.com/report;

That’s much better security hygiene:

  • fewer third-party domains
  • less policy sprawl
  • easier review during future changes
  • fewer false assumptions when debugging

If you want ready-to-use patterns, csp-examples.com is handy for comparing policy shapes before you ship one.

Self-hosted or proxied Plausible changes the policy

This team later considered proxying Plausible through their own domain to avoid another third-party hostname in CSP and to reduce the chance of blockers interfering.

That changes the policy again.

Before proxying

script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic' https://plausible.io;
connect-src 'self' https://plausible.io;

After proxying through same origin

script-src 'self' 'nonce-{{NONCE}}' 'strict-dynamic';
connect-src 'self';

And the script becomes:

<script
  nonce="{{ .CSPNonce }}"
  defer
  data-domain="csp-guide.example"
  src="/js/plausible.js"></script>

With events sent to a same-origin endpoint like:

<script
  nonce="{{ .CSPNonce }}"
  defer
  data-domain="csp-guide.example"
  data-api="/api/event"
  src="/js/plausible.js"></script>

From a CSP perspective, same-origin analytics is cleaner. Operationally, it adds proxying complexity. I usually tell teams to start with hosted Plausible and only proxy if they have a concrete reason.

Common mistakes I keep seeing

1. Adding only script-src

This is the most common miss. The script loads, but beacons fail.

For Plausible you generally need:

script-src ... https://plausible.io;
connect-src ... https://plausible.io;

2. Forgetting nonce behavior

If your app uses nonce-based CSP, don’t slap random inline analytics config into the page without a nonce and expect it to work.

Bad:

<script>
  window.plausible = window.plausible || function(){};
</script>

Better:

<script nonce="{{ .CSPNonce }}">
  window.plausible = window.plausible || function(){ (window.plausible.q = window.plausible.q || []).push(arguments) }
</script>

3. Leaving old analytics domains forever

I get why people do this. Nobody wants to be the one who breaks tracking.

But stale CSP entries are attack surface and maintenance debt. If GTM is gone, remove GTM. If GA is gone, remove GA.

4. Overusing default-src

Don’t rely on default-src to implicitly cover everything and call it a day. Be explicit for the directives that matter. Analytics nearly always needs script-src and connect-src called out directly.

The outcome

Once the team updated both directives and removed the obsolete GA/GTM entries, Plausible worked immediately:

  • pageviews started showing up
  • custom events were delivered
  • CSP violation noise dropped
  • the policy got shorter and easier to audit

That last point matters more than people think. A CSP nobody understands is a decorative header. A CSP your team can read at a glance is actually useful.

If I were doing this on a production content site today, I’d keep it boring:

  • use a nonce-based script-src
  • explicitly allow Plausible in script-src and connect-src
  • remove legacy analytics domains
  • consider proxying later, not on day one
  • keep reporting enabled so you catch breakage fast

That’s usually enough to get privacy-friendly analytics without turning your CSP into a landfill.