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:
script-src 'nonce-…' 'strict-dynamic' https:— the nonce authorises the GTM container snippet;'strict-dynamic'lets every script GTM injects (gtag.js, GA4, third-party tags) inherit that trust without listing each host.https:is a CSP2 fallback that modern browsers ignore. If you cannot adopt'strict-dynamic', the host-list alternative isscript-src 'self' https://www.googletagmanager.com— but that will not cover third-party tags GTM loads.connect-src— GA4 sends measurement hits withnavigator.sendBeacon()/fetch()towww.google-analytics.comand, for Google Consent Mode and server-side routing, to*.analytics.google.com.region1.google-analytics.com(and the wildcard*.google-analytics.com) covers regional endpoints GA4 selects at runtime.www.googletagmanager.comappears here because GTM also fetches its config overconnect-srcin some container versions.img-src— GA4 falls back to a 1×1 GIF pixel (/collectand/g/collect) whensendBeaconis unavailable, and GTM serves noscript pixels. Without these hosts the fallback path is blocked and hits are silently lost.object-src 'none'/base-uri 'self'— standard hardening that GTM does not need relaxed.
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).
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.
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:
- Network tab, filter
google—gtm.jsreturns200,gtag.jsloads, andcollect/g/collectrequests towww.google-analytics.comreturn204 No Content(a successful GA4 hit). - Console — no
Refused to load the script 'https://www.googletagmanager.com/gtm.js…' because it violates … script-srcand noRefused to connect to 'https://www.google-analytics.com/g/collect…'messages. Either message names the exact directive to widen. - Application → Frames → console — GA4 real-time reports should register the pageview within seconds.
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'defeats the whole exercise. The tempting fix for a blocked GTM snippet isscript-src 'unsafe-inline'. That re-enables every inline<script>on the page — the exact injection vector CSP exists to close — and browsers ignore any nonce once'unsafe-inline'is present without'strict-dynamic'. Use the nonce; never fall back to'unsafe-inline'. GTM custom-HTML tags that need inline execution inherit the nonce via'strict-dynamic', so'unsafe-inline'is never required.
- Google Consent Mode and regional endpoints. GA4 in EU regions routes hits to
region1.google-analytics.comand other*.google-analytics.com/*.analytics.google.comsubdomains chosen at runtime. Omitting the wildcards blocks a fraction of traffic depending on user region — always include both wildcard forms inconnect-src. - GTM Preview / debug mode is stricter. GTM’s Preview mode loads an iframe and assets from
tagmanager.google.comand may needframe-src https://tagmanager.google.complusstyle-srcfor its overlay. Add these only if you preview on the live-policy domain; production visitors never hit them. Note preview also interacts with frame-ancestors controls if you frame the site. - Third-party tags GTM loads. A GTM container that injects a chat widget or ad pixel pulls scripts from other hosts. Under
'strict-dynamic'these inherit trust automatically; under a host-list policy you must add each vendor domain toscript-srcand their beacon hosts toconnect-src.'strict-dynamic'is strongly preferred precisely because the tag set changes without a code deploy.
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.