Advanced CSP guide
CSP strict-dynamic: How to Allow Dynamically Injected Scripts Without unsafe-inline
You added a nonce-based Content-Security-Policy and Google Tag Manager is now blocked — even though GTM itself has the nonce. This guide explains why, what 'strict-dynamic' does, and how to build a policy that allows GTM and the scripts it injects without surrendering the XSS protection you just added.
- The problem with nonces alone
- What strict-dynamic does
- The complete policy pattern
- GTM + GA4 implementation
- Static sites: an honest limitation
- Test in report-only mode
- Verify with HardenCheck
- FAQ
The problem with nonces alone
A nonce-based CSP works by generating a random token on every request, stamping it on your legitimate <script> tags, and including it in the header. The browser runs any script that carries the correct nonce; everything else — including any script an attacker injects — is blocked because they can't know the current nonce.
This works perfectly for scripts you load directly. But Google Tag Manager does not load scripts directly — it injects them. When GTM fires a GA4 pageview tag, it calls document.createElement('script') and sets src to https://www.googletagmanager.com/gtag/js. That dynamically created <script> element has no nonce attribute, because GTM has no way to know your server's current nonce. The browser sees a nonce-free script, checks your CSP, finds no matching nonce and no matching domain (if you're being strict), and blocks it.
The naive fix is to add https://www.googletagmanager.com to your script-src allowlist. That works, but it means the browser will execute any script from that CDN — including any future scripts served under that domain, or any script an attacker could get hosted there. The allowlist approach weakens the very XSS protection you added the CSP for.
What strict-dynamic does
The 'strict-dynamic' keyword instructs the browser to propagate trust from a nonced (or hashed) script to any scripts that script dynamically creates. The logic: if you explicitly trusted a script with a nonce, then scripts that script creates at runtime are presumably part of the same trusted execution. The trust flows downward through the script tree.
A second, critical behaviour: in modern browsers, when 'strict-dynamic' is present alongside a valid nonce, host-based allowlists in script-src are ignored. The browser doesn't trust https://www.googletagmanager.com just because it's in the list — it trusts scripts created by scripts you explicitly nonced. This is a feature: a nonce + propagation model is strictly stronger than an allowlist, because it doesn't require you to enumerate every CDN domain that might ever inject another script.
For older browsers that don't understand 'strict-dynamic' (Internet Explorer, Edge before version 79), the keyword is silently ignored. You can include a host allowlist as a fallback that these browsers will use. Modern browsers will ignore it; older browsers will use it. The two halves coexist peacefully in the same header.
| Browser | strict-dynamic support | Fallback allowlist used? |
|---|---|---|
| Chrome 52+ | Supported | No — allowlist ignored |
| Firefox 52+ | Supported | No — allowlist ignored |
| Safari 15.4+ | Supported | No — allowlist ignored |
| Edge 79+ (Chromium) | Supported | No — allowlist ignored |
| Internet Explorer | Not supported | Yes — allowlist enforced |
The complete policy pattern
The recommended pattern for a server-rendered site that uses GTM or other tag loaders:
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
style-src 'self' https://fonts.googleapis.com 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https:;
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
The script-src value breaks down like this:
'nonce-{RANDOM}'— your per-request nonce; replace{RANDOM}with a fresh base64-encoded random value on every response.'strict-dynamic'— propagates trust from any nonced script to scripts it dynamically inserts.https:— fallback for browsers that don't support strict-dynamic; allows any HTTPS script. Modern browsers ignore this when a nonce is present.'unsafe-inline'— a second fallback for very old browsers; modern browsers with nonce support also ignore this. It provides no additional risk in nonce-aware browsers.
object-src allows Flash and plugin-based script execution; a missing base-uri allows an attacker who can inject a <base> tag to redirect all relative URLs including the nonce-carrying script itself.
GTM + GA4 implementation
Here is what the GTM snippet looks like when you add a nonce to it:
<!-- Google Tag Manager (add nonce to both script tags) -->
<script nonce="YOUR_NONCE_VALUE">
(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;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXXX');
</script>
<!-- End Google Tag Manager -->
And the nofscript fallback in the body:
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
With 'strict-dynamic' in place, the browser runs the GTM inline snippet (because it has the nonce), GTM creates a new <script> element pointing to gtm.js, and the browser allows it because it was created by a trusted (nonced) script. GA4, conversion pixels, and any other tags GTM fires follow the same path: all trusted because they are grandchildren of the nonced GTM snippet. No allowlist of CDN domains required.
In a Node/Express server, generate and inject the nonce into both the header and the template on every request:
const crypto = require('crypto');
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader(
'Content-Security-Policy',
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
`object-src 'none'; base-uri 'none'; frame-ancestors 'none';`
);
next();
});
// In your template (EJS example):
// <script nonce="<%= nonce %>"> ... GTM snippet ... </script>
In Next.js 13+ (app router), inject the nonce via middleware and pass it to the root layout:
// middleware.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';
export function middleware(request) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = [
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
`object-src 'none'`,
`base-uri 'none'`,
`frame-ancestors 'none'`,
].join('; ');
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', csp);
response.headers.set('x-nonce', nonce); // read in layout.tsx
return response;
}
Static sites: an honest limitation
Nonces must change on every HTTP response. A static HTML file has a fixed nonce baked into its content — once someone reads your page source, they know the nonce, and it is no longer a secret. This means 'strict-dynamic' cannot be used correctly on a fully static site deployed directly to GitHub Pages, Netlify, or Cloudflare Pages without a server-side component or edge middleware.
For static sites that need to load GTM or other tag injectors, your options are:
- Hash-based CSP for your own inline scripts + a domain allowlist for third-party CDNs (the allowlist approach, with its broader attack surface).
- Edge middleware — Cloudflare Workers or Netlify Edge Functions can inject a per-request nonce into both the HTML and the response header. This turns a static site into a nonce-capable one at the edge.
- Self-host GTM's container snapshot — advanced; avoids the dynamic-script-injection problem entirely by serving a point-in-time copy of the container.
If none of these are feasible for your project, the honest answer is that a host allowlist plus a strict default-src 'self' is still much better than no CSP at all.
Test in report-only mode first
Any change to a live CSP policy can break things you didn't anticipate. Use Content-Security-Policy-Report-Only with your new 'strict-dynamic' policy before enforcing it:
Content-Security-Policy-Report-Only: script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none';
Open your site, fire every GTM tag you expect (pageviews, events, conversions), and watch the DevTools console. Violations will show up as warnings — they are not blocked in report-only mode. Look for any script that shows a "Refused to load" warning that you didn't expect to see. A violation from a script you don't recognise is a legitimate finding worth investigating; a violation from a known analytics endpoint means you have a policy gap to close before switching to enforce mode.
Switch to the enforcing Content-Security-Policy header only when report-only mode is quiet for all the workflows you care about.
Verify with HardenCheck
Once you've deployed the policy, paste your raw response headers into HardenCheck's paste mode. The CSP card will grade your policy and flag any remaining weaknesses — a missing object-src or base-uri, the presence of a domain wildcard that undermines the policy, or a default-src that's too permissive. You can iterate on the policy value and re-paste without touching your server.
Grade my headers with HardenCheck →
Frequently asked questions
What does strict-dynamic actually do?
When a script is loaded with a valid nonce or hash, 'strict-dynamic' grants that script permission to dynamically insert other scripts (via createElement, document.write, or similar DOM APIs), and those child scripts are trusted automatically — without needing their own nonce or a matching domain in your allowlist. Trust flows from the nonced script to anything it creates, one level deep per script execution.
Does strict-dynamic remove my domain allowlist?
In modern browsers (Chrome 52+, Firefox 52+, Safari 15.4+, Edge 79+), yes: when a valid nonce or hash is present and 'strict-dynamic' is set, host-based allowlists in script-src are silently ignored. You include them anyway as a fallback for older browsers that don't understand 'strict-dynamic'. This is by design — the allowlist creates a larger attack surface than nonce + propagation.
Can I use strict-dynamic on a purely static site?
Not easily. Nonces must change on every request — a static HTML file has a fixed nonce baked in, so an attacker who reads the source can reuse it. 'strict-dynamic' only provides its intended protection when you have server-side rendering or edge middleware that can inject a fresh nonce per request. For fully static sites, use hashes for inline scripts you control, and accept that dynamically loaded third-party tags may require a host allowlist without 'strict-dynamic'.
Does strict-dynamic allow unsafe-inline?
No. 'strict-dynamic' does not relax restrictions on inline scripts without a nonce. An inline <script> block that lacks a matching nonce is still blocked. The purpose of 'strict-dynamic' is to allow the children of nonced scripts — not to open the door to arbitrary inline code.
Why is GTM blocked even though I gave it a nonce?
GTM is allowed because it has a nonce on its snippet. But GTM works by injecting new <script> tags — for GA4, conversion pixels, Hotjar, and every other tag you fire. Those dynamically-created elements have no nonce (GTM can't know your server's current nonce), so a nonce-only CSP blocks them. 'strict-dynamic' solves this by propagating trust from GTM's nonced snippet to everything GTM creates at runtime.
Does HardenCheck grade my CSP for strict-dynamic?
HardenCheck grades your CSP for 'unsafe-inline', missing directives, and weak fallback values. Paste your raw headers — including the full Content-Security-Policy value — into HardenCheck's paste mode to confirm the policy is present and see any remaining weaknesses like a missing object-src or base-uri.
What is the difference between strict-dynamic and unsafe-inline?
'unsafe-inline' allows any inline script on the page to execute — including one an attacker injected via XSS. It effectively eliminates CSP's main XSS protection. 'strict-dynamic' does the opposite: it requires a nonce on every directly-loaded script, then propagates that trust to scripts those allowed scripts create at runtime. An attacker who injects a script tag still can't run it because they don't know the current nonce.
Also in the Copper Bay Labs ship-safety suite
A strong CSP is one layer of several. These free tools cover the rest of what you need before you ship:
Paste code or a config file and see exposed API keys and secrets flagged before they reach the repo.
Post-deploy ExposureCheckScan your live URL for exposed .env files, .git directories, source maps, and bundled secrets.
Paste your package.json and see vulnerable, abandoned, typosquatted, and risky-license packages flagged by severity.
Check your site for WCAG accessibility failures and privacy-risk patterns before they become liabilities.