CSP for Google Tag Manager & Analytics

This guide is part of the Content-Security-Policy (CSP) reference and gives you the exact source expressions that make Google Tag Manager (GTM), Google Analytics 4 (GA4) and gtag.js load and beacon correctly under a strict policy. GTM is a script loader: the container snippet fetches more scripts and fires network beacons at runtime, so a naive script-src 'self' blocks the container, and a lax 'unsafe-inline' defeats the entire point of running a policy. The correct approach is a small, precise allowlist of Google hostnames per directive, plus a per-request nonce carried into GTM so custom-HTML tags execute without weakening script-src.

Configuration Syntax & Exact Values

GTM/GA4 touch three CSP directives: script-src (the container and the libraries it loads), connect-src (the GA4 measurement beacons, which are fetch/sendBeacon calls), and img-src (the legacy pixel fallback). The full string, with a nonce entry point and 'strict-dynamic' so GTM’s own injected scripts inherit trust:

Content-Security-Policy: script-src 'nonce-r4nd0mBase64' 'strict-dynamic' https:; connect-src 'self' https://www.google-analytics.com https://*.analytics.google.com https://*.google-analytics.com https://region1.google-analytics.com https://www.googletagmanager.com; img-src 'self' https://www.googletagmanager.com https://www.google-analytics.com https://*.google-analytics.com; object-src 'none'; base-uri 'self'

Annotated directive breakdown:

The domains matter exactly as written: www.google-analytics.com (not google-analytics.com bare), www.googletagmanager.com (not tagmanager.google.com, which is only the editing UI, not the runtime).

GTM and GA4 network flow mapped to CSP directives The browser loads the GTM container over script-src, GTM injects gtag and GA4 which inherit trust, and GA4 sends beacons over connect-src with an img-src pixel fallback. Browser page + nonce GTM container googletagmanager.com gtag.js / GA4 inherits trust GA4 endpoint google-analytics.com script-src strict-dynamic connect-src img-src pixel fallback
Each GTM/GA4 hop lands in a specific directive; missing any one blocks that stage silently.

Server-Side Configuration

The header is static except for the nonce, which must be minted per request (see generating CSP nonces per request). Placeholders below stand for that per-response value.

Nginx

add_header Content-Security-Policy "script-src 'nonce-$request_id' 'strict-dynamic' https:; connect-src 'self' https://www.google-analytics.com https://*.analytics.google.com https://*.google-analytics.com https://region1.google-analytics.com https://www.googletagmanager.com; img-src 'self' https://www.googletagmanager.com https://www.google-analytics.com https://*.google-analytics.com; object-src 'none'; base-uri 'self'" always;

always emits the header on error responses too, so a 404 or 500 is protected as well. $request_id is a stopgap — prefer an application-generated cryptographic nonce and mark these responses uncacheable. See the Nginx security headers guide for the surrounding server block.

Apache

Header always set Content-Security-Policy "script-src 'nonce-%{REQUEST_NONCE}e' 'strict-dynamic' https:; connect-src 'self' https://www.google-analytics.com https://*.analytics.google.com https://*.google-analytics.com https://region1.google-analytics.com https://www.googletagmanager.com; img-src 'self' https://www.googletagmanager.com https://www.google-analytics.com https://*.google-analytics.com; object-src 'none'; base-uri 'self'"

Header always set attaches the header across all status codes and requires mod_headers. %{REQUEST_NONCE}e reads an environment variable your app sets per request — never hardcode the nonce. Full context in the Apache hardening guide.

Node/Express (Helmet)

const crypto = require('crypto');
const helmet = require('helmet');

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  next();
});

app.use(helmet.contentSecurityPolicy({
  useDefaults: false,
  directives: {
    scriptSrc: [
      (req, res) => `'nonce-${res.locals.nonce}'`,
      "'strict-dynamic'",
      'https:',
    ],
    connectSrc: [
      "'self'",
      'https://www.google-analytics.com',
      'https://*.analytics.google.com',
      'https://*.google-analytics.com',
      'https://region1.google-analytics.com',
      'https://www.googletagmanager.com',
    ],
    imgSrc: [
      "'self'",
      'https://www.googletagmanager.com',
      'https://www.google-analytics.com',
      'https://*.google-analytics.com',
    ],
    objectSrc: ["'none'"],
    baseUri: ["'self'"],
  },
}));

The scriptSrc function is evaluated per response so the nonce matches the value you render into the GTM snippet. See the Helmet configuration guide for defaults you may want to keep.

Cloudflare (Transform Rule / Worker)

// Cloudflare Worker: rewrite the CSP header on the response
const csp = [
  "script-src 'nonce-" + nonce + "' 'strict-dynamic' https:",
  "connect-src 'self' https://www.google-analytics.com https://*.analytics.google.com https://*.google-analytics.com https://region1.google-analytics.com https://www.googletagmanager.com",
  "img-src 'self' https://www.googletagmanager.com https://www.google-analytics.com https://*.google-analytics.com",
  "object-src 'none'",
  "base-uri 'self'",
].join('; ');
newResponse.headers.set('Content-Security-Policy', csp);

A static Cloudflare Transform Rule cannot inject a per-request nonce; use a Worker (or the nonce from your origin) when 'strict-dynamic' is in play. See Cloudflare headers.

Propagating the nonce into GTM

GTM custom-HTML tags inject their <script> at runtime. Without 'strict-dynamic' you must pass the page nonce to GTM so it stamps injected scripts with it. Set a Custom JavaScript variable that reads the nonce, then reference {{nonce}} — actually GTM reads it automatically when you enable the container’s nonce support. The reliable path is to render the container snippet with the nonce and rely on 'strict-dynamic':

<script nonce="r4nd0mBase64">
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
j.nonce='r4nd0mBase64';f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXXX');
</script>

The added j.nonce='…' line stamps GTM’s own gtm.js request with the nonce. With 'strict-dynamic', that trust then flows to every tag GTM injects — no per-tag hostname needed.

Nonce propagation from server to GTM custom-HTML tags A per-request nonce is rendered into the GTM snippet, stamped onto the gtm.js element, and inherited by injected tags under strict-dynamic. Server mints nonce GTM snippet nonce in tag gtm.js element j.nonce stamped Injected tags inherit trust strict-dynamic Same nonce value travels the whole chain
One nonce per response flows server → snippet → gtm.js → injected tags; break the chain and tags are refused.

Diagnostic & Verification Steps

Confirm the header ships, then verify GTM loads, GA4 beacons succeed, and nothing is blocked.

curl -sI https://example.com/ | grep -i content-security-policy

Expected output (one line, nonce value will differ):

content-security-policy: script-src 'nonce-Yz2bQk9...' 'strict-dynamic' https:; connect-src 'self' https://www.google-analytics.com https://*.analytics.google.com https://*.google-analytics.com https://region1.google-analytics.com https://www.googletagmanager.com; img-src 'self' https://www.googletagmanager.com https://www.google-analytics.com https://*.google-analytics.com; object-src 'none'; base-uri 'self'

Verify the nonce actually reaches the rendered GTM snippet:

curl -s https://example.com/ | grep -o 'nonce="[^"]*"' | head -1

Expected: a nonce="…" attribute whose value matches the script-src 'nonce-…' token from the header. If they differ, the browser refuses the container.

In Chrome DevTools:

Paste the policy into Google’s CSP Evaluator (csp-evaluator.withgoogle.com) to confirm the nonce entry point is recognised and https: is flagged only as an ignored fallback, not a live weakness.

Edge Cases, Security Implications & Safe Rollback

unsafe-inline versus nonce plus strict-dynamic A comparison matrix showing that unsafe-inline loads GTM but leaves the page open to injection, while nonce plus strict-dynamic loads GTM and blocks injected scripts. 'unsafe-inline' nonce + 'strict-dynamic' GTM loads yes yes Injected script blocked no yes XSS surface wide open closed Verdict policy is theatre policy is real
Both load GTM; only the nonce approach keeps the policy meaningful against injection.

Rollback (reversible): the change is header-only and non-destructive. To revert, restore your previous Content-Security-Policy value and reload the service. Browsers immediately resume the old policy; no analytics data model or GTM container is altered.

# Revert: restore the prior CSP string, then:
# nginx -t && systemctl reload nginx

Conclusion

Roll this out in stages. Deploy the policy as Content-Security-Policy-Report-Only on staging first so any blocked GTM tag or GA4 beacon surfaces as a report instead of lost data, confirm gtm.js loads and collect returns 204, then promote to enforcing mode on production with a short max-age on any related HSTS work and the nonce minted per response. Keep the *.google-analytics.com and *.analytics.google.com wildcards permanently — GA4 selects regional endpoints you cannot enumerate ahead of time.

Frequently Asked Questions

Why is my GA4 collect request blocked even though GTM loads? script-src and connect-src are separate directives. GTM loads under script-src, but the GA4 beacon is a fetch/sendBeacon governed by connect-src. Add https://www.google-analytics.com and the *.google-analytics.com / *.analytics.google.com wildcards to connect-src; a blocked beacon logs Refused to connect in the console.

Can I use 'unsafe-inline' to make GTM work quickly? Do not. 'unsafe-inline' re-enables every inline script on the page and is ignored the moment a nonce is present without 'strict-dynamic', so it both weakens the policy and may not even fix GTM. Render the container snippet with a per-request nonce and add 'strict-dynamic' so GTM’s injected tags inherit trust.

Do I need to list every third-party tag domain? Only if you avoid 'strict-dynamic'. With script-src 'nonce-…' 'strict-dynamic', any script GTM injects inherits trust and needs no hostname in the policy, so new tags work without redeploying the header. Their network beacons still need their hosts in connect-src or img-src.

Why www.google-analytics.com and not the bare domain? GA4 and gtag.js request the www host for the measurement library and /collect endpoints; the bare google-analytics.com is not the runtime origin. Matching the exact host prevents both over-permissioning and blocked hits. Include the region1. and wildcard subdomains for regionally routed traffic.

Does the nonce need to change on every request? Yes. A nonce is only meaningful if it is unpredictable and single-use per response; a static nonce is equivalent to 'unsafe-inline' to an attacker who can read the page. Generate a fresh cryptographic value per request and mark the response uncacheable, as covered in the per-request nonce guide.