AcceptedDecided
ADR 0006: Per-request CSP nonce
Next.js streams inline <script> tags as part of its server-rendered HTML - these carry the hydration payload that React needs to take over the page.
On this page
- Status: Accepted
- Date: 2026-05-16
- Deciders: @jseifeddine
Context#
Next.js streams inline <script> tags as part of its server-rendered HTML - these carry the
hydration payload that React needs to take over the page. A strict Content Security Policy must
allow those inline scripts somehow.
The options are:
script-src 'unsafe-inline'- allow any inline script. Defeats most of CSP's protection.script-src 'self' 'sha256-<hash>'- hash-pin known inline scripts. Doesn't work when the inline content varies per request (which it does for hydration).script-src 'self' 'nonce-<random>' 'strict-dynamic'- issue a per-request random nonce, include it on every inline script tag we want to allow, trust further scripts loaded by those trusted ones.
Decision#
We use option 3: a per-request nonce generated in proxy.ts (the Next 16 proxy convention -
formerly middleware.ts), propagated to Next.js'
inline scripts via the x-nonce request header, with 'strict-dynamic' so we don't have to list
every chunk URL.
Rationale#
- Real CSP, not theatre. Options 1 and 2 produce a CSP that exists but doesn't protect.
Option 3 actually stops injected
<script>tags from executing. 'strict-dynamic'keeps the policy simple. Without it we'd have to enumerate every chunk URL Next produces, which changes on every build.- Next.js supports it natively. Reads
x-noncefrom the request and attaches the nonce to its framework scripts automatically.
Trade-offs (the honest part)#
- Every response goes through the proxy. This is a tiny per-request cost (~1ms for nonce generation), but it does mean CDN cacheability is reduced. Acceptable for an admin app; would be reconsidered for a public-facing site.
crypto.getRandomValuesworks in both the Edge and Node runtimes, so the nonce generator is correct wherever the proxy runs.crypto.randomUUID()(request id) andbtoaare likewise available in both.- CSS still uses
'unsafe-inline'for style-src because Tailwind injects critical CSS at render time. Not load-bearing for security since style injection has a much smaller attack surface than script injection.
Consequences#
- All inline scripts in the app must carry
nonce={nonce}from a server component / route handler. Forgetting this means the script silently fails to execute. - Browser DevTools shows the nonce on every script - that's fine; the protection comes from the nonce being unpredictable per request, not from being secret.
- E2E tests that inject scripts via Playwright must use Playwright's evaluation context, not string injection.
References#
proxy.ts(the implementation; renamed frommiddleware.tsin the Next 16 upgrade)- Next.js CSP guide
- MDN: CSP nonce-source