Migrate from unsafe-inline to nonce-based CSP
Table of Contents
If your CSP still depends on 'unsafe-inline', you’re carrying a security exception that attackers love.
I get why teams keep it around. Inline scripts are everywhere: templating fragments, analytics snippets, consent tools, framework hydration blobs, old event handlers, quick fixes nobody wants to touch. Removing 'unsafe-inline' sounds simple until you open the codebase and realize half the frontend was assembled by three generations of developers and two marketing vendors.
Nonce-based CSP is usually the cleanest migration path when you can’t immediately externalize every script. It lets you keep some inline scripts, but only the ones you explicitly bless per request. That’s a huge improvement over allowing all inline JavaScript.
The short version
Here’s the tradeoff:
'unsafe-inline'
Pros
- Easy
- Works with old code
- No server-side nonce generation needed
- Minimal coordination with frontend templates
Cons
- Effectively disables one of the most valuable CSP protections
- Any injected inline script can run
- Makes XSS much easier to exploit
- Encourages bad patterns like inline handlers and script blobs
Nonce-based CSP
Pros
- Stronger XSS protection
- Lets you keep necessary inline scripts during migration
- Works well with modern SSR apps and templating systems
- Pairs nicely with
'strict-dynamic'
Cons
- Requires per-request nonce generation
- You need to thread the nonce through templates
- Breaks if cached HTML reuses stale nonces
- Inline event handlers like
onclickstill need refactoring
If you’re deciding between “leave 'unsafe-inline' for now” and “move to nonces,” I’d move to nonces every time.
What unsafe-inline really does
When you add this:
Content-Security-Policy: script-src 'self' 'unsafe-inline';
you’re telling the browser to allow inline JavaScript such as:
<script>
doSensitiveThing();
</script>
<button onclick="stealCookies()">Click me</button>
That includes attacker-injected inline code if you have an XSS bug. CSP is supposed to reduce the blast radius of those mistakes. 'unsafe-inline' removes that safety net for scripts.
For styles, the risk is different and usually less catastrophic than script execution, but 'unsafe-inline' in style-src is still worth removing later. Script migration should come first.
What a nonce-based CSP looks like
A nonce is a random value generated for each HTTP response. The same value appears in the CSP header and on trusted inline <script> tags.
Example:
Content-Security-Policy: script-src 'self' 'nonce-r4nd0m123' 'strict-dynamic'; object-src 'none'; base-uri 'self';
Then in the HTML:
<script nonce="r4nd0m123">
window.appConfig = { env: "prod" };
</script>
If an attacker injects:
<script>alert(1)</script>
it won’t run, because it doesn’t carry the valid nonce.
That’s the core win.
A real-world header and what it tells us
Here’s the CSP header from headertest.com:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-YjJkNDBhMGUtNzY3MS00MWRiLWI0NGEtMWRmY2U4YWM0ODcz' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com; frame-src 'self' https://consentcdn.cookiebot.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
This is a pretty realistic “mid-migration” policy.
What’s good:
script-srcuses a nonce'strict-dynamic'is presentobject-src 'none',base-uri 'self', andframe-ancestors 'none'are strong defaults- Third-party domains are explicitly scoped
What still stands out:
style-src 'unsafe-inline'is still there- That’s common, especially with consent managers and tag tooling
- Script security is ahead of style security, which is a sensible order of operations
This is exactly how many teams should approach migration: fix scripts first, then come back for styles.
Migration options compared
Option 1: Keep unsafe-inline and clean up later
This is the “we know it’s bad, but shipping matters” path.
When it makes sense
- Legacy app with heavy inline JS
- No server rendering control
- Frontend templates are fragmented across systems
- You need a stopgap before a bigger rewrite
Pros
- Fastest rollout
- Lowest engineering effort
- Least likely to break production immediately
Cons
- Security benefit is weak
- You may never come back to fix it
- Reporting noise continues because policy isn’t really strict
- Attackers don’t care that your cleanup is “planned for Q4”
I’ve seen this turn into permanent debt more times than teams admit.
Option 2: Big-bang move to nonce-based CSP
This means generating a nonce for every response and updating all trusted inline scripts at once.
Pros
- Cleaner end state
- Faster removal of insecure patterns
- Security gains happen immediately
Cons
- Easy to break pages
- Hard to coordinate across apps, layouts, tag managers, and edge caches
- Painful if you still have inline event handlers everywhere
This works best for smaller apps or teams with very strong template ownership.
Option 3: Incremental migration to nonce-based CSP
This is the approach I recommend most often.
Start by:
- Generating nonces server-side
- Applying them to the inline scripts you control
- Removing
'unsafe-inline'fromscript-src - Leaving
style-src 'unsafe-inline'temporarily if needed - Refactoring old handlers and script blocks over time
Pros
- Real security improvement early
- Lower rollout risk
- Easier to debug
- Fits real production systems
Cons
- You’ll carry a transitional policy for a while
- Some code patterns need manual rewrites
- Teams need discipline to finish the migration
Messy? Yes. Practical? Also yes.
What you need to change in code
1. Generate a cryptographically random nonce per response
Node/Express example:
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString("base64");
next();
});
app.use((req, res, next) => {
const nonce = res.locals.cspNonce;
res.setHeader(
"Content-Security-Policy",
`default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`
);
next();
});
2. Add the nonce to trusted inline scripts
<script nonce="{{ cspNonce }}">
window.appConfig = {
apiBase: "/api",
buildId: "{{ buildId }}"
};
</script>
3. Refactor inline event handlers
This won’t work under a nonce-based policy:
<button onclick="submitForm()">Save</button>
Replace it with:
<button id="save-btn">Save</button>
<script nonce="{{ cspNonce }}">
document.getElementById("save-btn").addEventListener("click", submitForm);
</script>
Better yet, move that script into an external file.
4. Watch your caching
This is where a lot of nonce rollouts fail.
If your CDN or reverse proxy caches HTML containing a nonce, but serves it with a different CSP header later, scripts break. Or worse, you accidentally reuse nonces across users.
Rules I stick to:
- Generate nonce per response
- Don’t cache HTML with embedded nonces unless your setup is designed for it
- Be careful with fragment caching and edge-side includes
- Verify server-rendered templates, middleware, and proxies all agree
strict-dynamic: why it’s usually worth adding
If you already have nonce-based script-src, add 'strict-dynamic' unless you have a specific compatibility reason not to.
Example:
script-src 'self' 'nonce-abc123' 'strict-dynamic';
This tells the browser to trust scripts loaded by an already trusted nonce-bearing script. That’s useful when modern apps bootstrap loaders dynamically.
Without it, you often end up maintaining brittle allowlists for every script origin. With it, the nonce becomes the primary trust mechanism.
For ready-made policy patterns, https://csp-examples.com is handy.
Common gotchas
Third-party snippets
Analytics, tag managers, consent tools, and A/B testing scripts often assume inline execution. Some support nonces cleanly, some don’t. Check their official docs before rollout.
Framework hydration
Next.js, Nuxt, custom SSR, and other frameworks may inject inline state or bootstrap code. Most can work with nonces, but the wiring differs.
style-src confusion
Removing 'unsafe-inline' from script-src does not fix styles. That’s a separate migration. Don’t block progress on scripts because styles are still messy.
Report-Only first
Use Content-Security-Policy-Report-Only during rollout if you’re not confident yet. It lets you collect violations without breaking users.
Official docs:
- CSP overview: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
script-src: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-srcstrict-dynamic: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#strict-dynamic
My recommendation
If you’re migrating from 'unsafe-inline', don’t wait for the perfect cleanup.
Move script-src to nonces first. Keep the scope tight. Add 'strict-dynamic'. Refactor inline handlers. Leave style-src 'unsafe-inline' for a second phase if you need to. That’s a normal and defensible migration path.
The biggest mistake is treating CSP migration like a purity exercise. It’s not. It’s risk reduction. A partial move away from 'unsafe-inline' for scripts is already a meaningful security win.