Engineering

Content Security Policy Nonces in Next.js

Generate a per-request nonce, wire it through CSP headers and React context, and see which inline scripts pass — and which fail — under a strict policy.

View reference repo

A strict Content Security Policy is one of the highest-leverage browser defenses against XSS: if an attacker injects a <script> into your HTML, the browser refuses to run it unless the policy allows that script. The hard part in a React / Next.js app is that legitimate inline scripts still appear — bootstrapping, analytics snippets, theme flash prevention — and a blunt script-src 'self' policy breaks them.

Nonces solve that tension. Each response gets a cryptographically random value; the CSP header whitelists that value; only inline scripts that carry the matching nonce attribute execute. Our public demo at dreamerkumar/csp-nonce-demo walks through the full flow in a Pages Router Next.js app, with working and broken examples side by side.

Core insight

A nonce is not a secret shared with the attacker's payload. It is a per-request allowlist token that must appear both in the Content-Security-Policy header and on the script tags you intentionally ship. Anything injected later without that nonce is blocked.

What CSP and Nonces Buy You

CSP restricts where scripts, styles, images, and other resources may load from. For scripts, the important directive is script-src. Without a nonce (or hash), allowing inline scripts usually means 'unsafe-inline' — which largely defeats XSS protection. With a nonce:

  • The server generates a fresh value every request (for example crypto.randomBytes(16).toString('base64')).
  • The response includes script-src 'self' 'nonce-…'.
  • Trusted inline scripts set nonce={nonce} (or the DOM equivalent).
  • Injected inline scripts without the attribute fail with a CSP violation.

Demo pages in the reference repo

  • Working inline script — uses the nonce from React context; the script runs.
  • Broken inline script — omits the nonce; the browser fires securitypolicyviolation and the page surfaces the error.
  • Inline / function handlers — React synthetic events are not raw inlineonclick attributes in the final sense CSP cares about for script tags; useful contrast for teams migrating handlers.

Per-Request Generation in _document.tsx

In the Pages Router, custom _document is the right place to mint the nonce and attach CSP to the document. Generation must happen on the server for every request — never bake a static nonce into the client bundle.

import crypto from 'crypto';

function generateNonce(): string {
  return crypto.randomBytes(16).toString('base64');
}

// Inside getInitialProps:
const nonce = generateNonce();
// Stash on res.locals so _app can read the same value during SSR
if (ctx.res) {
  (ctx.res as any).locals = { ...(ctx.res as any).locals, nonce };
}
return { ...originalProps, nonce };

The render path then builds the policy string and applies the nonce to <Head>, NextScript, and a small bootstrap script that exposes the value to the browser:

const cspHeader = `
  default-src 'self';
  script-src 'self' 'nonce-${nonce}'${isDev ? " 'unsafe-eval'" : ""};
  style-src 'self' 'unsafe-inline';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
`.replace(/\s+/g, ' ').trim();

// meta http-equiv or preferably a real Content-Security-Policy response header
<script
  nonce={nonce}
  dangerouslySetInnerHTML={{
    __html: `window.__NONCE__ = "${nonce}";`,
  }}
/>

Production note

Prefer setting CSP as an HTTP response header (middleware, reverse proxy, or headers() in App Router) rather than only a meta http-equiv tag. Headers are harder to strip and are what most scanners and CDNs expect. The demo uses the document path to keep the teaching surface in one place.

Threading the Nonce Through React

Client components cannot invent the nonce. They must receive the same value the server put in the CSP header. The demo uses three layers:

  1. _app.getInitialProps — on the server, read res.locals.nonce; in the browser, read window.__NONCE__.
  2. NonceContext — provide the string to the tree.
  3. useNonce() — a thin hook so pages stay readable.
function MyApp({ Component, pageProps, nonce }: MyAppProps) {
  return (
    <NonceContext.Provider value={nonce}>
      <Component {...pageProps} />
    </NonceContext.Provider>
  );
}

With that in place, a page that needs a dynamic inline script can create a <script> element (or use dangerouslySetInnerHTML on a script) and assign script.nonce = useNonce(). Under a correct policy, that script runs; a copy-pasted XSS payload without the attribute does not.

Allowed vs Blocked — What Teams Should Expect

Allowed

  • External scripts from 'self' (or listed hosts)
  • Inline scripts that include the current request's nonce
  • Next.js runtime scripts when NextScript receives the nonce

Blocked

  • Inline scripts missing the nonce attribute
  • Injected third-party snippets that assume unsafe-inline
  • Stale nonces reused across requests (defeats the model; always regenerate)

The broken-demo page listens for securitypolicyviolation and prints the violated directive and blocked URI. That pattern is worth keeping in staging: CSP failures are silent to end users unless you surface reports (report-uri / Reporting API) or watch the console.

React Event Handlers vs Raw Inline Scripts

A common confusion: JSX like onClick={() => …} does not ship as an HTML onclick="…" string that CSP treats as an inline script. React attaches listeners through its event system. The demo's handler pages exist to make that distinction concrete — migrate dangerous string-based handlers, but do not assume every JSX on* prop needs a nonce.

Checklist before rolling CSP to production

  • Generate a unique nonce per request; never embed a fixed nonce in source.
  • Pass the same nonce into CSP, NextScript / framework script tags, and any intentional inline scripts.
  • Start in report-only mode (Content-Security-Policy-Report-Only) to catch third-party breakage.
  • Allow 'unsafe-eval' only in development if tooling requires it; strip it in production.
  • Prefer HTTP headers over meta tags; document App Router middleware equivalents if you migrate off Pages Router.

Why This Matters for Product Surfaces Like RuleCMS

Headless CMS and composable UIs often inject HTML, custom components, and third-party embeds. A nonce-based CSP is how you keep the host application strict while still allowing the scripts you author. Treat the demo as a teaching scaffold: copy the generation and context pattern, then tighten directives (img-src, connect-src, frame ancestors) to match your real threat model.

Clone the reference implementation, run the working and broken routes locally, and confirm violations in DevTools before you flip enforcement on in production:

git clone https://github.com/dreamerkumar/csp-nonce-demo.git
cd csp-nonce-demo
npm install
npm run dev