CSP Academy

← The ten steps · Step 6 of 10 · 7 minutes

Nonces and strict-dynamic

The answer to step 5 is to stop describing where scripts come from and start describing which scripts you meant.

The nonce

Generate a random value on every response. Put it on your script tags and in your policy. They have to match, so only script tags that were in the HTML you generated will run.

Content-Security-Policy: script-src 'nonce-r4nd0mV4lu3'
<script nonce="r4nd0mV4lu3" src="/app.js"></script>
<script nonce="r4nd0mV4lu3">
  // inline is fine too, as long as it carries the nonce
</script>

An attacker injecting a script tag cannot supply the nonce, because they do not know it — it is different on every response and it is not in the DOM anywhere they can read it before their payload runs.

The generation rule matters and there are only two parts to it: cryptographically secure randomness, and at least 128 bits. In practice:

PHP     base64_encode(random_bytes(16))
Node    crypto.randomBytes(16).toString('base64')
Python  secrets.token_urlsafe(16)
Go      rand.Read on 16 bytes, then base64

The problem the nonce alone does not solve

Real applications load scripts that load other scripts. A tag manager injects vendor tags. A bundler loads chunks on demand. Those child scripts are created by JavaScript at runtime, and nothing gives them a nonce — so a nonce-only policy breaks them.

The old fix was to add every one of those origins to the allowlist, which puts you straight back into step 5.

'strict-dynamic'

Add it and the rule changes fundamentally: a script loaded by an already-trusted script is itself trusted, regardless of where it comes from. Trust propagates by mechanism rather than by origin.

script-src 'nonce-r4nd0mV4lu3' 'strict-dynamic'

Your nonced bootstrap can now load whatever it legitimately needs. An injected <script> tag still cannot run, because markup injected into the page is parser-inserted and parser-inserted scripts do not inherit trust — only scripts created programmatically by trusted code do.

The part that surprises everyone

When 'strict-dynamic' is present, the browser ignores 'self', 'unsafe-inline', and every host expression in that directive. All of them. This policy:

script-src 'nonce-abc' 'strict-dynamic' 'self' https://cdn.example.com

is exactly equivalent to:

script-src 'nonce-abc' 'strict-dynamic'

That is not a bug, it is the entire point — the allowlist is what was failing, so 'strict-dynamic' switches it off. Those extra sources are there purely as a fallback for browsers too old to understand 'strict-dynamic', and in current ones they do nothing.

It also means that if you add 'strict-dynamic' to an existing allowlist policy and something breaks, the fix is not to add more hosts. The fix is to find the script that should have carried a nonce and give it one.

The target policy

default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic';
style-src 'self';
img-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'none';
form-action 'self';
frame-ancestors 'none';
report-uri <your endpoint>

No host allowlist for script, so no JSONP endpoint, no gadget, no upload host and no forgotten subdomain can be used against you. This is what a CSP is supposed to look like in 2026, and it is the policy this site serves itself.

You can do this now

Find where your application renders its <head>. That is where the nonce has to be generated and where it has to reach the template. Knowing which file that is, and whether your templating layer can pass a value into every script tag, is most of the work of adopting this.