CSP Nonce vs unsafe-inline: How to Allow Inline Scripts Safely
If a checker just flagged your Content-Security-Policy for 'unsafe-inline' in script-src, you're standing at a decision that looks small but isn't: nonce, hash, or leave it as-is. A CSP nonce is a random, single-use token your server generates for each page response and matches against a nonce attribute on your script tags, so the browser can tell "this is the script I meant to load" from "this is whatever got injected." That distinction is the entire point of CSP, and it's also the thing 'unsafe-inline' throws away.
This isn't a syntax choice between three equally valid options. It's a decision about which layer of your site actually controls what code runs in a visitor's browser, and getting it wrong at the wrong layer is how sites end up with a policy that looks strict in the header and does nothing in practice. That gap between "looks strict" and "is strict" is also why a checker grade is a starting point, not a verdict.
Why unsafe-inline Isn't a Smaller Version of a Nonce
Three layers are involved here, and mixing them up is where most CSP confusion starts. The server layer is whatever generates your response headers (Apache config, nginx config, a PHP header() call, a WordPress filter). The markup layer is the actual HTML your server outputs, including every <script> tag and its attributes. The enforcement layer is the browser itself, reading the CSP header and deciding, script by script, whether to run it.
'unsafe-inline' is a server-layer instruction that tells the enforcement layer to stop checking. Every inline script runs, no questions asked. It doesn't matter if the script came from your analytics snippet or from an attacker who found a reflected XSS hole in your comment form. The browser can't tell the difference, because you told it not to look.
That's not a theoretical concern. Google's internet-scale measurement of CSP deployments, published at ACM CCS 2016, analyzed 1,680,867 hosts and 26,011 distinct policies pulled from a roughly 100-billion-page crawl. The result: 94.72% of those policies contained a flaw an attacker could use to bypass them, 94.68% of policies attempting to restrict script execution failed to do so effectively, and 99.34% of hosts running any CSP at all got no meaningful XSS protection from it. This is the paper that pushed strict-dynamic into the CSP spec as the fix, because host allowlisting and blanket unsafe-inline were both quietly failing at scale (Weichselbaum et al., ACM CCS 2016).
Worth naming directly: swapping 'unsafe-inline' for a long list of trusted domains isn't automatically better. The same study found 14 of the 15 most commonly allowlisted script-hosting domains contained endpoints an attacker could abuse to smuggle in arbitrary script, and 75.81% of distinct policies used allowlists exploitable that way. A domain list feels safer than a wildcard. It often isn't.
What a CSP nonce is and how it works
A nonce ("number used once") is a random value, generated fresh for every single HTTP response, that gets placed in two spots: the CSP header and the script tag it's meant to authorize.
Content-Security-Policy: script-src 'nonce-r4nd0mV4lu3H3r3'
<script nonce="r4nd0mV4lu3H3r3">
console.log('this script runs');
</script>
The browser checks: does this script's nonce attribute match a nonce named in the header? If yes, it runs. If no, it's blocked, and that includes any script an attacker manages to inject through a form field, a URL parameter, or a vulnerable plugin, because they have no way to know or guess that response's nonce value before it's generated.
There's a mechanical detail worth knowing here, because it removes a common worry: you don't need to strip 'unsafe-inline' out entirely to support older browsers. Per MDN's documentation on the script-src directive, any browser that understands 'nonce-...' or a hash source automatically ignores 'unsafe-inline' in that same directive. Browsers old enough not to understand nonces fall back to 'unsafe-inline' instead of breaking entirely. Shipping both together is the documented graceful-degradation pattern, not a contradiction in your policy (MDN, Content-Security-Policy: script-src).
One honest limitation: the nonce is visible in your page's raw HTML source to anyone who views it. It protects against the browser executing script it shouldn't, not against someone reading your source code. That's the correct job for a nonce. Don't expect it to do more.
Nonce vs hash: the decision rule
A CSP hash works differently: instead of a random per-response token, you compute a SHA cryptographic hash of the exact script content and list that hash in the header. Same enforcement outcome, different mechanism, and the two aren't interchangeable in practice. Here's how to pick.
| Situation | Use | Why |
|---|---|---|
| Inline script content changes per request or per user (a GTM bootstrap snippet, a config object with a session ID, a CSRF token dump) | Nonce | A hash would need recalculating on every change; a nonce doesn't care what's inside the script tag |
| Static inline script that never changes (a small fixed analytics init block, an inline JSON-LD block) | Hash | No server-side generation needed per request; the hash is computed once and stays valid |
| Site sits behind a CDN or full-page cache | Hash, or a nonce with extra engineering | A nonce must be unique per response, which conflicts with serving one cached HTML page to many visitors |
| Third-party script that loads its own sub-scripts (Google Tag Manager, a widget loader) | Nonce, paired with strict-dynamic | You can't hash scripts you don't control the exact content of; a nonce on the loader propagates trust downstream (more on this below) |
The caching row deserves a straight answer. If your HTML is served from a CDN edge cache and you can't easily generate a fresh header per visitor, a nonce is awkward, not impossible. Some teams solve it with edge-side substitution, some exclude the HTML document from caching while caching assets separately, some scope the cache key to a session.
There's no single universal fix here. If you're not behind a CDN or full-page cache, this trade-off doesn't apply to you and a nonce is the simpler, more flexible default.
strict-dynamic: when one trusted script loads others
Google Tag Manager doesn't just run its own inline snippet. It goes on to load a pile of sub-scripts (analytics tags, conversion pixels, third-party widgets) that you didn't write and can't hash in advance. Teams following older setup guides hit this wall constantly and default to adding 'unsafe-inline' just to get GTM working, which undoes the policy entirely for the sake of one integration.
'strict-dynamic' solves this specific problem. Add it alongside a nonce, and any script that a nonce-verified script subsequently loads is automatically trusted too, without you needing to name every downstream host. Per MDN's directive reference, 'strict-dynamic' also causes the browser to ignore host and scheme allowlist entries like 'self' or specific domains in that same directive, so it isn't something you bolt onto an existing allowlist. It replaces the need for one.
The broadly documented implementation pattern for third-party tag managers under CSP follows the same order of preference: nonce first, hash second, 'unsafe-inline' only as a last resort, with an explicit warning to weigh the security trade-off before reaching for it. That order is precisely reversed from the shortcut most GTM setup guides quietly default to.
A commonly recommended policy shape looks like this:
Content-Security-Policy: script-src 'nonce-r4nd0mV4lu3H3r3' 'strict-dynamic' https: 'unsafe-inline';
That trailing https: and 'unsafe-inline' aren't a mistake. They're fallback tokens for browsers old enough not to understand strict-dynamic or nonces, and per the same-directive ignore rule covered earlier, any browser that does understand the nonce simply disregards them.
Two ways "correct" nonce setups quietly become fake fixes
This is the part most guides skip, and it's the reason a nonce in your header doesn't automatically mean you're protected.
Blanket middleware that nonces every script tag
Some teams solve "add nonces everywhere" with a middleware or find-and-replace step that scans the outgoing HTML and appends a nonce attribute to every <script> tag it finds. That approach has a specific blind spot: a nonce authorizes a script element, not the content inside it. The W3C's CSP Level 3 specification is explicit that the browser treats a nonce as a strict string match against the nonce attribute and "doesn't actually care about any underlying value," which is exactly why the spec devotes a section to nonce hijacking and retargeting risks (W3C, Content Security Policy Level 3).
If an attacker's injection point lands inside the body or the src attribute of a tag your middleware has already nonced, whether because it ran before the injection happened or because it nonces indiscriminately, the malicious code carries a valid nonce right along with it. The nonce still matches. The policy still "passes." None of that stopped the attack, because the nonce authorized a tag, not the specific code an attacker managed to smuggle into it.
Reusing the same nonce value
A 2023 University of Trento study looked at 2,271 websites running nonce-based CSP and found 598 of them, about 26.3%, reused the exact same nonce value across multiple responses. In 561 of those cases the same value was reused across every visitor session, meaning anyone could view the page source, copy the nonce, and use it to smuggle a script past the policy. Functionally, that's 'unsafe-inline' wearing a nonce as a costume (Golinelli, Bonomi, Crispo, arXiv:2309.07782).
This usually happens for a boring reason: someone hardcoded the nonce, or generated it once at server startup instead of per request. It technically satisfies the syntax. It defeats the purpose.
Both failure modes share a root cause: treating the nonce as a header-layer checkbox instead of coordinating it with the markup layer, request by request. The fix only works if the nonce is genuinely unpredictable and genuinely scoped to that one response.
Neither failure mode shows up by reading your own header value: a middleware bug or a static nonce both produce a CSP header that looks fine on its own. If you're about to switch a site from 'unsafe-inline' to a nonce-based policy, running it as Content-Security-Policy-Report-Only first, and pointing violation reports somewhere you'll actually read them, is the cheapest way to catch a middleware or reuse bug before it reaches an enforced policy. The report-only rollout guide covers reading those violation reports and knowing when it's safe to flip enforcement on.
How to add a nonce in PHP
Generate the nonce early in the request, before any output, and reuse the same value for both the header and every script tag on that page.
<?php
// Generate once, at the top of the request
$csp_nonce = base64_encode(random_bytes(16));
header("Content-Security-Policy: script-src 'nonce-$csp_nonce' 'strict-dynamic' https:;");
?>
<!DOCTYPE html>
<html>
<head>
<script nonce="<?php echo $csp_nonce; ?>">
console.log('authorized inline script');
</script>
</head>
</html>
random_bytes(16) gives you 128 bits of cryptographically strong randomness, which lines up with the "128 bits minimum, newly generated per response" guidance that shows up across CSP implementation writeups. Base64-encoding it keeps the value URL-safe and header-safe. The part people skip: this has to run fresh on every request. Cache the value across requests, or generate it once and store it in a config file, and you've recreated the reuse problem from the Trento study by hand.
How to add a nonce in WordPress
WordPress 5.7 added the wp_script_attributes and wp_inline_script_attributes filters specifically so developers could attach a nonce attribute (along with async or defer) to enqueued scripts without hacking apart WordPress's own output string by string (Make WordPress Core, 5.7 release notes). A minimal setup in functions.php looks like this:
function my_csp_nonce() {
if ( ! defined( 'CSP_NONCE' ) ) {
define( 'CSP_NONCE', base64_encode( random_bytes( 16 ) ) );
}
return CSP_NONCE;
}
add_action( 'send_headers', function() {
header( "Content-Security-Policy: script-src 'nonce-" . my_csp_nonce() . "' 'strict-dynamic' https:;" );
});
add_filter( 'wp_inline_script_attributes', function( $attributes ) {
$attributes['nonce'] = my_csp_nonce();
return $attributes;
});
One correction worth making clearly, because it shows up in a fair number of practitioner write-ups: don't reach for WordPress's built-in wp_create_nonce() here. That function is WordPress's own anti-CSRF mechanism, it's action-scoped, and its value stays valid for a window of roughly 12 to 24 hours rather than being unique to a single HTTP response. Using it as your CSP nonce reproduces the exact reuse problem the Trento study measured. Generate your CSP nonce independently with random_bytes(), scoped to one request.
If functions.php editing isn't something you want to maintain, the "No unsafe-inline" plugin on WordPress.org handles nonce and hash clustering for inline scripts without custom code, at the cost of being one more dependency to keep updated. Either way, the nonce lives inside the Content-Security-Policy response header alongside whatever else your server sends, so if you're not sure what that full header set looks like right now, a plain breakdown of what each response header does is worth a look before you start troubleshooting the wrong layer.
Confirm the fix actually cleared the flag
Once your nonce or hash is in place, run the page back through the securityheaders.tools checker and confirm the csp_unsafe_inline deduction is gone. A CSP that looks strict in your editor and a CSP that the browser actually enforces are two different things until you've fetched the live response and checked. If you'd rather have the policy built correctly from the start, the generator's "strict" preset assembles a nonce plus strict-dynamic policy automatically.
For the rest of your header stack beyond CSP, the breakdown of each security header and its trade-offs covers what the other cards on your report are actually grading, from HSTS to framing rules.
Frequently Asked Questions
What is a CSP nonce?
A CSP nonce is a random, cryptographically strong value that a server generates fresh for every HTTP response and includes in both the Content-Security-Policy header and the matching script tag's nonce attribute. The browser only runs inline scripts whose nonce matches the one named in that response's header, which lets legitimate scripts through while blocking anything an attacker manages to inject, since they can't predict the value before it's generated.
Why is unsafe-inline bad?
Because it tells the browser to stop distinguishing between scripts you authored and scripts an attacker injected. Google's internet-scale study of CSP deployments found 94.72% of measured policies contained a bypassable flaw and 99.34% of CSP-deploying hosts got no meaningful XSS protection from their policy, largely because of allowlisting patterns like 'unsafe-inline' and broad host lists that don't actually restrict what runs.
Nonce or hash: which should I use?
Use a nonce when the inline script's content changes per request or per user, such as an analytics bootstrap snippet or a config block with a session value, since a hash would need recalculating every time the content changes. Use a hash for static inline content that never changes, since it avoids the need to generate anything fresh per response. If your site sits behind a CDN or full-page cache, a nonce adds friction because it needs to be unique per response, so a hash is often the simpler fit there.
How do I add a nonce in PHP or WordPress?
In plain PHP, generate the value once per request with base64_encode(random_bytes(16)), send it via a header() call before any output, and echo the same value into each script tag's nonce attribute. In WordPress, use the wp_script_attributes and wp_inline_script_attributes filters added in WordPress 5.7 to attach the nonce to enqueued scripts, generating the value fresh per request rather than using wp_create_nonce(), which is WordPress's separate anti-CSRF mechanism and isn't scoped correctly for this purpose.