How to Safely Remove unsafe-inline from Your Content Security Policy
Replace unsafe-inline with nonces, hashes, and strict-dynamic to restore real XSS protection to your CSP
You added a Content Security Policy to your site. Then inline scripts broke, so you added 'unsafe-inline' to get things working again. The header is there, but the protection is not. As long as unsafe-inline is in your script-src, an attacker who finds an injection point can still execute arbitrary JavaScript on your page.
This guide walks through the complete process of removing unsafe-inline from both script-src and style-src. It covers the three replacement mechanisms (nonces, hashes, and strict-dynamic), explains when to use each one, and includes working server configurations so you can apply the changes immediately.
Before diving into the solutions, it helps to understand exactly why unsafe-inline is there in the first place and what makes it dangerous.
Why unsafe-inline exists (and why it is dangerous)#
A Content Security Policy is an HTTP response header that tells the browser which resources are allowed to load and execute on your page. Here is what a basic CSP looks like:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'
This policy says: only load scripts and styles from my own domain. No external sources, no inline code. That is strict, and it blocks XSS effectively. If an attacker injects <script>alert('xss')</script> into your page, the browser refuses to execute it because it is inline code and the policy does not allow inline code.
The problem is that most real websites use inline code. Analytics snippets in the <head>, small initialization scripts, event handlers like onclick, and CSS in JS libraries that inject <style> tags at runtime. When a strict CSP blocks all of these, the quick fix is:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'
This allows every inline script and style on your page to execute. Your legitimate code works again, but so does any code an attacker injects. The browser cannot tell the difference between your inline script and a malicious one, because unsafe-inline tells it not to check.
The key point: A CSP with 'unsafe-inline' in script-src provides almost no XSS protection. You have the header, but it is not doing its job. The goal is to remove unsafe-inline and replace it with a mechanism that distinguishes your legitimate inline code from injected code.
The three replacements for unsafe-inline#
CSP provides three mechanisms that let you allow specific inline code without opening the door to everything. Each one has different strengths, and you will likely use a combination of them.
1. Nonces
A nonce is a random string your server generates fresh on every request. You include it in the CSP header and add a matching nonce attribute to each inline script or style tag. The browser only executes inline code whose nonce matches the header.
Best for: Dynamic server rendered pages, applications where you control the HTML output, any page that already goes through a server or templating engine on each request.
2. Hashes
You compute a SHA-256 (or SHA-384/SHA-512) hash of your inline script's exact contents and add it to the CSP header. The browser computes the same hash at runtime and only executes the script if the hashes match.
Best for: Static inline scripts that never change between deployments, sites hosted on static platforms (CDN, S3, Netlify) where you cannot generate nonces per request.
3. strict-dynamic
The 'strict-dynamic' keyword tells the browser that scripts loaded by an already trusted script (one that passed the nonce or hash check) are also trusted. This eliminates the need to list every external domain in your policy.
Best for: Applications that dynamically load scripts (code splitting, tag managers, third party widgets). Pair it with nonces for the strongest setup.
Step 1: Remove unsafe-inline from script-src with nonces#
This is the highest priority change. Removing unsafe-inline from script-src immediately gives you real XSS protection. Here is the process.
Generate a nonce on every request
The nonce must be cryptographically random and unique to each page load. If you reuse the same nonce across requests, an attacker who sees one response can predict the nonce for the next one. Here is how to generate it in Node.js, Python, and PHP:
const crypto = require('crypto');
app.use((req, res, next) => {
// Generate 16 random bytes, encode as base64
res.locals.nonce = crypto.randomBytes(16).toString('base64');
next();
});import secrets
import base64
class CSPNonceMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request.csp_nonce = base64.b64encode(
secrets.token_bytes(16)
).decode('ascii')
response = self.get_response(request)
return response<?php
$nonce = base64_encode(random_bytes(16));
header("Content-Security-Policy: script-src 'self' 'nonce-$nonce'");
?>Add the nonce to every inline script
Every <script> tag that contains inline code (no src attribute) needs a nonce attribute. The value must match exactly what you put in the CSP header.
<!-- Before: blocked by CSP without unsafe-inline --> <script> window.analyticsId = 'UA-123456'; </script> <!-- After: allowed by nonce --> <script nonce="R4nd0mB4se64=="> window.analyticsId = 'UA-123456'; </script>
Set the CSP header with the nonce
The header includes the same nonce value. Notice that the nonce in the header is prefixed with nonce- and wrapped in single quotes.
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-R4nd0mB4se64==' 'strict-dynamic'; style-src 'self'
Important behavior: When a nonce is present in script-src, the browser automatically ignores 'unsafe-inline' in the same directive. This means you can include both during migration for backward compatibility with older browsers: modern browsers use the nonce, older browsers fall back to unsafe-inline.
Complete Express.js example
// server.js
const crypto = require('crypto');
const express = require('express');
const app = express();
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
"style-src 'self' 'nonce-" + nonce + "'",
"img-src 'self' data: https:",
"connect-src 'self'",
"font-src 'self'",
"object-src 'none'",
"base-uri 'self'"
].join('; '));
next();
});
// In your EJS template:
// <script nonce="<%= nonce %>">
// window.config = { apiUrl: '/api' };
// </script>Step 2: Use hashes for static inline scripts#
If your site is hosted on a static platform (Netlify, Vercel static export, S3 + CloudFront, GitHub Pages), you cannot generate a nonce per request because there is no server rendering each page. In this case, hashes are your solution.
A hash is computed from the exact content of your inline script. You include the hash in your CSP header, and the browser independently computes the same hash at runtime. If they match, the script runs. If the content differs by even one character (including whitespace), the hash will not match and the browser blocks it.
How to compute a hash
The hash covers everything between the opening and closing script tags, including whitespace and newlines. Here is how to compute it:
<script>window.analyticsId = 'UA-123456';</script>
echo -n "window.analyticsId = 'UA-123456';" | \ openssl dgst -sha256 -binary | base64
Content-Security-Policy: script-src 'self' 'sha256-BASE64HASHVALUE='
Tip: Chrome's console makes this easier. When a CSP blocks an inline script, the error message includes the exact hash you need to add. Open DevTools, look for the CSP violation in the Console tab, and copy the sha256-... value directly into your header.
When hashes work well (and when they do not)
| Scenario | Hashes | Nonces |
|---|---|---|
| Static inline script that never changes | Great fit | Also works |
| Script with dynamic content (user data, config) | Will not work | Great fit |
| Static hosting (no server rendering) | Only option | Not possible |
| Many inline scripts that change often | Maintenance heavy | Great fit |
Step 3: Add strict-dynamic for dynamically loaded scripts#
Modern web applications rarely load all their JavaScript upfront. Code splitting means your app might load dozens of script files dynamically after the initial page load. Without strict-dynamic, you would need to either list every chunk URL in your CSP (which changes on every build) or give each dynamically created script tag a nonce (which requires modifying your bundler).
The 'strict-dynamic' keyword solves this. It tells the browser: if a script was loaded by a trusted script (one that passed the nonce or hash check), treat it as trusted too. Trust propagates down the chain.
Content-Security-Policy: default-src 'self'; script-src 'nonce-R4nd0mB4se64==' 'strict-dynamic'; style-src 'self' 'nonce-R4nd0mB4se64=='; object-src 'none'; base-uri 'self'
Notice that there are no host allowlists in script-src. When strict-dynamic is present, the browser ignores host allowlists and unsafe-inline in the same directive. All trust flows from the nonce. Your nonced script loads a bundled chunk, that chunk loads another chunk, and they all execute. An attacker's injected script has no nonce, so it is blocked, and nothing it tries to load will be trusted either.
Backward compatible header
Older browsers that do not support strict-dynamic will ignore it and fall back to the other values in the directive. You can include host allowlists and unsafe-inline as fallbacks. Modern browsers will ignore them (because strict-dynamic is present), and older browsers will use them:
Content-Security-Policy:
script-src 'nonce-R4nd0mB4se64==' 'strict-dynamic'
https://cdn.example.com 'unsafe-inline'Modern browsers see the nonce and strict-dynamic, so they ignore the https://cdn.example.com and unsafe-inline parts entirely. CSP Level 2 browsers see the nonce and ignore unsafe-inline. CSP Level 1 browsers see unsafe-inline and the host allowlist as a fallback. This way every browser gets the strongest policy it supports.
Step 4: Remove unsafe-inline from style-src#
Removing unsafe-inline from style-src is lower priority than script-src, because CSS injection cannot directly execute JavaScript. However, it is still a real attack vector. Attackers can use injected CSS to exfiltrate data through background-image URLs, read form values with attribute selectors, and overlay fake UI elements to trick users.
The approach is the same: use nonces on your <style> tags. Most CSS in JS libraries (styled components v6+, Emotion, MUI) support a nonce prop that gets attached to every injected style tag. If your styles are in static CSS files loaded via <link> tags, you do not need unsafe-inline at all because external stylesheets are controlled by host allowlists, not the inline keyword.
The main challenge is style attributes on HTML elements (like <div style="color: red">). CSP treats these as inline styles, and nonces cannot be applied to style attributes. Your options are: move the styles to a class in a stylesheet, or use the 'unsafe-hashes' keyword with a hash of the specific attribute value. In practice, refactoring to classes is almost always the better approach.
Step 5: Replace inline event handlers#
Inline event handlers like onclick, onload, onsubmit, and onerror are blocked by CSP when unsafe-inline is removed. Nonces do not work on event handler attributes. The only CSP mechanism for them is 'unsafe-hashes', but the better solution is to remove them entirely and use addEventListener instead.
<!-- Before: blocked by CSP -->
<button onclick="handleClick()">Submit</button>
<img onerror="handleImageError(this)" src="photo.jpg" />
<!-- After: works with strict CSP -->
<button id="submit-btn">Submit</button>
<img id="photo" src="photo.jpg" />
<script nonce="R4nd0mB4se64==">
document.getElementById('submit-btn')
.addEventListener('click', handleClick);
document.getElementById('photo')
.addEventListener('error', function() {
handleImageError(this);
});
</script>This is often the most time consuming part of the migration, especially on older codebases with hundreds of inline event handlers scattered across templates. Search your codebase for onclick=, onload=, onsubmit=, onerror=, and onchange= to find them all. If you use a framework like React, Vue, or Angular, this is already handled for you because those frameworks use JavaScript event binding internally.
The safe migration strategy#
Do not remove unsafe-inline and deploy to production in one step. Use report only mode to find every inline script and style that needs a nonce, hash, or refactor. Here is the process:
- 1Deploy with report only. Add a
Content-Security-Policy-Report-Onlyheader with your target policy (nounsafe-inline, nonces in place). Keep the enforcing header withunsafe-inlineas your active policy. The report only header logs violations without blocking anything. - 2Review the violation reports. Each report tells you the directive that was violated, the blocked URI, and the line number. Work through each violation: add nonces to legitimate inline scripts, compute hashes for static ones, and refactor inline event handlers.
- 3Watch for false positives. Browser extensions inject inline scripts that trigger CSP violations. These are not your code and cannot be fixed on your end. Filter out violations where the blocked URI starts with
chrome-extension://ormoz-extension://. - 4When violations drop to zero (or only browser extensions), switch. Replace the enforcing header with your new policy. Remove
unsafe-inlinefrom the activeContent-Security-Policyheader. Keep areport-uriorreport-toendpoint active so you catch any regressions.
Server configurations#
Here are working configurations for the most common web servers. Each one generates a per request nonce and sets a CSP without unsafe-inline.
Nginx (with OpenResty or njs module)
Standard Nginx cannot generate random values per request. You need the njs module or OpenResty. Here is the njs approach:
# /etc/nginx/njs/csp_nonce.js
function generateNonce(r) {
const bytes = require('crypto').randomBytes(16);
return bytes.toString('base64');
}
export default { generateNonce };
# nginx.conf
load_module modules/ngx_http_js_module.so;
http {
js_import csp from /etc/nginx/njs/csp_nonce.js;
js_set $csp_nonce csp.generateNonce;
server {
location / {
sub_filter_once off;
sub_filter '<script>' '<script nonce="$csp_nonce">';
add_header Content-Security-Policy
"default-src 'self'; script-src 'nonce-$csp_nonce' 'strict-dynamic'; style-src 'self' 'nonce-$csp_nonce'; object-src 'none'; base-uri 'self'";
}
}
}Apache (with mod_headers and mod_unique_id)
# Apache's UNIQUE_ID provides a per-request token
# For a proper cryptographic nonce, use a reverse proxy
# or application-level middleware instead
Header set Content-Security-Policy \
"default-src 'self'; \
script-src 'self' 'nonce-%{UNIQUE_ID}e' 'strict-dynamic'; \
style-src 'self' 'nonce-%{UNIQUE_ID}e'; \
object-src 'none'; \
base-uri 'self'"Note: Apache's UNIQUE_ID is unique per request but not cryptographically random. For production use, generate the nonce in your application layer (PHP, Python, Ruby) rather than relying on the web server.
Common pitfalls#
Reusing the same nonce across requests
The nonce must be different on every single response. If you hardcode it, use a static value, or cache HTML pages with nonces in them, attackers can extract the nonce from one page and use it to execute scripts on another. This completely defeats the purpose.
Caching pages that contain nonces
If your CDN or reverse proxy caches HTML pages, every user gets the same nonce, which is the same as reusing it. Either bypass the cache for pages with nonces, use Cache-Control: no-store, or switch to hashes for static pages and only use nonces for server rendered pages that are not cached.
Forgetting about third party scripts
Removing unsafe-inline does not affect external scripts loaded via src attributes. Those are controlled by your host allowlist. But if those external scripts inject inline scripts at runtime (like Google Tag Manager does), you need strict-dynamic to let them work without unsafe-inline. See our third party scripts guide for the exact domains each service needs.
Using a weak nonce
The nonce must be generated using a cryptographically secure random number generator. Do not use Math.random(), timestamps, sequential counters, or any predictable value. Use crypto.randomBytes() in Node.js, secrets.token_bytes() in Python, or random_bytes() in PHP.
The bottom line#
Removing unsafe-inline is the single most impactful improvement you can make to an existing CSP. It is the difference between a header that looks good in a security report and one that actually prevents XSS. The work is mostly mechanical: audit your inline scripts, add nonces or hashes, refactor event handlers, and test with report only mode before enforcing.
Start with script-src first because that is where the real XSS risk lives. Then tackle style-src at your own pace. Use strict-dynamic if your app loads scripts dynamically. And always use report only mode during the transition to catch what you missed.
The goal is not a perfect policy on day one. It is a policy that actually blocks injected scripts, which means no unsafe-inline in script-src. Everything else is refinement.
Frequently asked questions#
What does unsafe-inline do in a Content Security Policy?
It tells the browser to allow all inline scripts, inline styles, and inline event handlers to execute. This effectively disables CSP's XSS protection because an attacker who injects inline code can run it freely.
How do CSP nonces work?
Your server generates a random string on every request, includes it in the CSP header as 'nonce-VALUE', and adds a matching nonce attribute to each legitimate inline script. The browser only runs scripts whose nonce matches. Since the nonce changes every request, an attacker cannot predict it.
Can I use hashes instead of nonces for inline scripts?
Yes. Compute a SHA-256 hash of your script's content and add it to the CSP as 'sha256-BASE64HASH'. Hashes work great for static scripts that never change. If the content varies (user data, configuration), nonces are easier to maintain.
Is unsafe-inline for styles as dangerous as for scripts?
Less dangerous, but still a real risk. Injected CSS cannot execute JavaScript directly, but attackers can exfiltrate data through background-image URLs, read form inputs with attribute selectors, and overlay fake UI. Remove it from script-src first, then work on style-src.
What is strict-dynamic and how does it help?
It tells the browser that scripts loaded by an already trusted (nonced or hashed) script should also be trusted. When strict-dynamic is present, the browser ignores unsafe-inline and host allowlists. All trust flows from nonces. This is essential for apps that dynamically load scripts.
Continue reading
Does your CSP still use unsafe-inline?
Scan your website to check your Content Security Policy for unsafe-inline and other common weaknesses.