CSP for Google Analytics 4 and GTM

script-src https://www.googletagmanager.com; img-src https://www.googletagmanager.com https://*.google-analytics.com; connect-src https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com

Google Analytics 4, loaded by the Google tag (gtag.js) or by Tag Manager, needs script-src https://www.googletagmanager.com, img-src https://www.googletagmanager.com https://*.google-analytics.com and connect-src https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com. That is the list in Google’s tag platform CSP guide for Analytics without Ads features. The inline snippet that starts the tag needs a nonce too: Google recommends one over 'unsafe-inline'.

The policy, with a nonce

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-REPLACE_ME' https://www.googletagmanager.com; img-src 'self' https://www.googletagmanager.com https://*.google-analytics.com; connect-src 'self' https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'

Your server swaps REPLACE_ME for a fresh random value on every response and puts the same value on the snippet: <script nonce="…">. Google’s nonce-aware GTM snippet copies it onto gtm.js, and Tag Manager passes it on to the scripts it adds. Google’s table says script-src-elem; script-src covers it, because script-src-elem falls back to script-src.

The builder rates this medium: a nonce beside a host allowlist, without 'strict-dynamic', still trusts everything on www.googletagmanager.com, and that host serves any container, an attacker’s included. The other finding is low: a <meta> tag ignores frame-ancestors, so send the policy as a header.

The strict variant

script-src 'nonce-REPLACE_ME' 'strict-dynamic' https://www.googletagmanager.com; object-src 'none'; base-uri 'none'

Google’s guide doesn’t mention 'strict-dynamic'. Under CSP Level 3 it trusts whatever the nonced snippet loads, so gtm.js and the tags it adds run, while browsers that support it ignore the host. The host stays for browsers without 'strict-dynamic', such as Safari before 15.4. Tap the 'strict-dynamic' chip in the builder below and the medium finding goes. With gtag.js, put the nonce on both of its <script> tags: under 'strict-dynamic' a host no longer vouches for a script in the HTML.

Gotchas

  • Regional hosts. gtag.js sends hits to a collection subdomain, such as region1.google-analytics.com and region1.analytics.google.com. A *. wildcard matches any depth of subdomain, so https://*.google-analytics.com and https://*.google.com cover both. Google’s guide of January 2025 listed https://*.analytics.google.com instead.
  • Ads features need more. Google suggests adding them up front: img-src adds https://*.google.com https://*.google.<TLD> https://*.g.doubleclick.net, connect-src adds https://*.google.<TLD> https://*.g.doubleclick.net https://pagead2.googlesyndication.com, plus frame-src https://www.googletagmanager.com. CSP has no wildcard for the end of a host, so each Google country domain is listed on its own.
  • Custom JavaScript variables come out undefined unless script-src has 'unsafe-eval', which the builder rates high. Google recommends custom templates instead, which don’t need it. A template that injects a script or sends a pixel still needs the hosts it reaches: in img-src or connect-src, and in script-src unless 'strict-dynamic' is on.
  • Preview mode needs https://tagmanager.google.com in script-src, style-src https://www.googletagmanager.com https://tagmanager.google.com https://fonts.googleapis.com, https://ssl.gstatic.com https://www.gstatic.com in img-src, and font-src https://fonts.gstatic.com data:.
  • Roll it out in Report-Only. Send Content-Security-Policy-Report-Only with a report-to endpoint first: nothing is blocked, and every violation is reported.

In Express

import crypto from 'node:crypto';
import helmet from 'helmet';

app.use((req, res, next) => {
  res.locals.cspNonce = crypto.randomBytes(16).toString('base64'); // 128 bits, new for every response
  next();
});
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`, "'strict-dynamic'", 'https://www.googletagmanager.com'],
      imgSrc: ["'self'", 'https://www.googletagmanager.com', 'https://*.google-analytics.com'],
      connectSrc: ["'self'", 'https://www.googletagmanager.com', 'https://*.google-analytics.com', 'https://*.google.com'],
    },
  },
}));

Try it in the CSP Builder

everything that is fetched and has no directive of its own
scripts, inline and loaded
images, favicons and CSS background images
fetch(), XMLHttpRequest, WebSocket and EventSource
<object>, <embed> and <applet>
the URLs a <base> element may point at
who may frame this page

A nonce has to be fresh for every response, so 'nonce-REPLACE_ME' is a placeholder: your server fills it in and puts the same value in the <script nonce=…> tag.

2 findings worst medium

7 directives

Content-Security-Policy

default-src 'self'; script-src 'self' 'nonce-REPLACE_ME' https://www.googletagmanager.com; img-src 'self' https://www.googletagmanager.com https://*.google-analytics.com; connect-src 'self' https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'

The same policy as a meta tag

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'nonce-REPLACE_ME' https://www.googletagmanager.com; img-src 'self' https://www.googletagmanager.com https://*.google-analytics.com; connect-src 'self' https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com; object-src 'none'; base-uri 'self'">

A meta tag only works in HTML, has to be in the <head>, and is read once: a header can be changed per response.

Findings · worst medium

  • medium · script-src A nonce or hash is listed here without 'strict-dynamic', so the host allowlist still decides what else may load. One JSONP endpoint or library gadget is enough for a script to get past it. Add 'strict-dynamic'.
  • low A <meta> tag ignores frame-ancestors. If this policy goes into HTML, that directive is doing nothing there: send it as a header.

Every directive, in plain words

DirectiveWhat it governsSources
default-srceverything that is fetched and has no directive of its own
  • 'self' this origin only
script-src
→ default-src
scripts, inline and loaded
  • 'self' this origin only
  • 'nonce-REPLACE_ME' the script carrying this nonce may run
  • https://www.googletagmanager.com the host www.googletagmanager.com over https
img-src
→ default-src
images, favicons and CSS background images
  • 'self' this origin only
  • https://www.googletagmanager.com the host www.googletagmanager.com over https
  • https://*.google-analytics.com any subdomain of google-analytics.com over https
connect-src
→ default-src
fetch(), XMLHttpRequest, WebSocket and EventSource
  • 'self' this origin only
  • https://www.googletagmanager.com the host www.googletagmanager.com over https
  • https://*.google-analytics.com any subdomain of google-analytics.com over https
  • https://*.google.com any subdomain of google.com over https
object-src
→ default-src
<object>, <embed> and <applet>
  • 'none' nothing of this kind may load
base-urithe URLs a <base> element may point at
  • 'self' this origin only
frame-ancestorswho may frame this page
  • 'self' this origin only

Checked against CSP Level 3: every directive, every source keyword, and the fallback chain that carries a directive to default-src. Nothing is uploaded, and no request is made to the site whose policy you paste.

Runs in your browser. Nothing you enter is sent anywhere.