Running a strict Content Security Policy alongside Google Tag Manager is where theory usually collides with production traffic.

I’ve seen the same pattern a few times: a team moves to server-side tagging because they want better control over analytics, fewer third-party requests in the browser, and a cleaner privacy story. Then they realize their CSP still looks like a traditional client-side GTM setup. They’re proxying measurement through their own tagging server, but the browser is still allowed to talk to half of Google’s infrastructure.

That’s wasted attack surface.

Here’s a real-world case study based on a production-style policy from headertest.com. The original header was:

content-security-policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-YzlkNWVkZTctYjFlMC00MTlkLTgxOTQtZWJjMWM0OTdhNTdk' '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'

This isn’t bad. It already uses a nonce and strict-dynamic, which is better than the average “allow everything from everywhere” policy. But it still reflects a browser-side analytics model, not a server-side one.

The setup

The site had:

  • Google Tag Manager on the page
  • Cookiebot for consent
  • Google Analytics
  • Some internal APIs and websocket endpoints
  • A move toward GTM server-side tagging

The goal was simple:

  1. Keep GTM working
  2. Move browser tracking requests to a first-party tagging endpoint
  3. Remove unnecessary third-party origins from CSP
  4. Keep the policy maintainable

That last point matters. A CSP nobody understands gets loosened the first time marketing says “tracking is broken.”

The mistake teams make with server-side tagging

A lot of developers assume that once they adopt GTM server-side tagging, CSP gets easier because “everything goes through our own domain now.”

Not quite.

You still need to think about two separate things:

  1. Where scripts load from
  2. Where the browser can send data

With server-side tagging, the browser often still loads the GTM container script from www.googletagmanager.com, unless you proxy or self-host it in a supported way. But measurement hits can often move from Google domains to your own tagging subdomain, which changes connect-src, img-src, and sometimes frame-src needs.

That’s the real win.

Before: browser still allowed to talk to Google directly

The original policy allowed this in connect-src:

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;

If you’ve moved GA collection through a server-side tagging endpoint like https://metrics.example.com, keeping both *.google-analytics.com and *.googletagmanager.com in connect-src means the browser can still exfiltrate data directly to those domains.

That weakens one of the best side effects of server-side tagging: reducing direct browser communication with third parties.

After: split script loading from data collection

The fixed policy kept GTM script loading intact, but tightened browser destinations.

A cleaner version looks like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com;
  style-src 'self' 'unsafe-inline' https://*.cookiebot.com https://consent.cookiebot.com;
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://api.headertest.com https://or.headertest.com wss://or.headertest.com https://metrics.csp-guide.test https://*.cookiebot.com;
  frame-src 'self' https://consentcdn.cookiebot.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  report-to csp-endpoint;
  report-uri /csp-report;

The big changes:

  • default-src was reduced to just 'self'
  • script-src still allows GTM and Cookiebot
  • connect-src now points to the first-party tagging server instead of Google analytics endpoints
  • *.google-analytics.com was removed
  • *.googletagmanager.com was removed from connect-src
  • reporting was added

That’s the pattern I like: allow GTM where it actually needs to execute, but don’t allow browser data collection to third-party endpoints if your server-side container is supposed to own that job.

Why default-src should stop being a dumping ground

The original policy had this:

default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;

I’d change that every time.

default-src is a fallback. If you stuff third-party domains into it, future resource types may quietly inherit access you didn’t mean to grant. That’s how policies drift.

Better:

default-src 'self';

Then explicitly list third-party domains only in the directives that need them.

The nonce + strict-dynamic part

The original header already did something right:

script-src 'self' 'nonce-YzlkNWVkZTctYjFlMC00MTlkLTgxOTQtZWJjMWM0OTdhNTdk' 'strict-dynamic' ...

That’s solid, assuming the nonce is generated per response and applied correctly to the bootstrap script.

For example:

<script nonce="{{ .CSPNonce }}">
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({ event: 'consent_default' });
</script>

<script async nonce="{{ .CSPNonce }}"
  src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX"></script>

And server-side:

import crypto from 'node:crypto';

export function cspMiddleware(req, res, next) {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.cspNonce = nonce;

  res.setHeader(
    'Content-Security-Policy',
    [
      "default-src 'self'",
      `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com`,
      "style-src 'self' 'unsafe-inline' https://*.cookiebot.com https://consent.cookiebot.com",
      "img-src 'self' data: https:",
      "font-src 'self'",
      "connect-src 'self' https://metrics.csp-guide.test https://*.cookiebot.com",
      "frame-src 'self' https://consentcdn.cookiebot.com",
      "frame-ancestors 'none'",
      "base-uri 'self'",
      "form-action 'self'",
      "object-src 'none'",
    ].join('; ')
  );

  next();
}

If you’re using nonces, use them consistently. Don’t bolt on a nonce and then keep a giant host allowlist because nobody trusts the nonce setup. Pick a model and make it real.

For ready-made policy shapes, https://csp-examples.com is useful for testing combinations before rollout.

What changed operationally

Once the site’s browser-side analytics requests were pointed at the server-side tagging endpoint, three things got better.

1. Fewer CSP exceptions

Before, every new analytics tweak risked adding another Google-origin exception. After, most changes stayed behind the first-party tagging endpoint. The browser policy stopped growing every sprint.

2. Better visibility

When connect-src only allows your own measurement endpoint, it becomes obvious when a script tries to send data somewhere unexpected. CSP reports become much more useful because violations are signal, not background noise.

With consent tooling like Cookiebot in the mix, routing tracking through a first-party endpoint makes behavior easier to reason about. You still need proper tag configuration server-side, but at least the browser isn’t spraying requests directly to multiple vendors.

A practical migration path

If you’re doing this on a live site, don’t jump straight to hard blocking.

I’d roll it out like this:

Step 1: inventory current browser destinations

Open DevTools and list every request caused by GTM, GA, consent tooling, and custom tags.

You want to know:

  • script origins
  • XHR/fetch/beacon origins
  • image beacon origins
  • iframe origins

Step 2: move measurement to the tagging server

For example, instead of allowing:

connect-src https://www.google-analytics.com https://region1.google-analytics.com;

move to:

connect-src 'self' https://metrics.csp-guide.test;

Your actual GTM server container setup should follow the official docs from Google Tag Manager and Google Analytics.

Step 3: keep script-src narrow

Usually this still needs GTM and whatever consent platform you use:

script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com;

Not https:. Not a giant wildcard. Just what actually executes.

Step 4: watch CSP reports before removing old hosts

Use Content-Security-Policy-Report-Only first:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com;
  connect-src 'self' https://metrics.csp-guide.test https://*.cookiebot.com;
  report-uri /csp-report;

Then remove old Google endpoints from enforcement once you’ve confirmed the browser no longer needs them.

Official reference for directive behavior lives in the MDN and browser documentation, but for CSP syntax and deployment details I still check the official CSP spec and vendor docs first.

The final takeaway

Server-side tagging does not magically give you a strict CSP. What it gives you is the chance to stop allowing the browser to talk directly to analytics vendors.

That’s the part worth cashing in.

If your CSP still includes broad Google analytics and tag manager destinations in connect-src after moving to server-side tagging, you probably haven’t finished the job. Keep GTM where it needs to load, route collection through your own endpoint, and trim everything else aggressively.

That’s how CSP starts acting like a security control instead of a compatibility checklist.