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 onclick still 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-src uses a nonce
  • 'strict-dynamic' is present
  • object-src 'none', base-uri 'self', and frame-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:

  1. Generating nonces server-side
  2. Applying them to the inline scripts you control
  3. Removing 'unsafe-inline' from script-src
  4. Leaving style-src 'unsafe-inline' temporarily if needed
  5. 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:

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.