Serve a real per-request CSP nonce with strict-dynamic

Featurekamo-marketing
Shipped
August 20, 2026 at 5:20 AM UTC
Author
Kamo
Commit
3271922

Clears both remaining High findings on script-src: the 'unsafe-inline' one and "host allowlists can frequently be bypassed". Only a nonce with 'strict-dynamic' clears those, and Next cannot supply one on a prerendered page — a nonce must be unique per response, so Next emits one only while rendering, and a prerendered route is served from the full-route cache without rendering. The documented workaround is to make every route dynamic, which would undo the static rendering this site depends on and collapse <Link> prefetch to a bare route tree. So the nonce is applied where the bytes leave the process. server-nonce.js starts the standalone server on loopback, serves the public port itself, and stamps each HTML response on the way out. Next keeps prerendering and keeps serving from its own cache; measured through the wrapper, /en/pricing still prefetches 77,521 bytes with 23 chunk references and answers x-nextjs-cache: HIT. Static rendering and a real rotating nonce, both. 'strict-dynamic' is what retires the allowlist finding: browsers ignore host allowlists under it and trust scripts created by already-trusted scripts. The chat widget and the analytics tracker are injected by next/script at runtime, so they are trusted transitively and need no allowlist entry at all. 'unsafe-inline' and https: remain only as the documented fallback for browsers too old to understand nonces, which ignore neither and were getting nothing before. Three things this turned up, none of which would have been visible from the config: · Forwarding the request CSP upstream is a trap. Next reads a nonce out of it and stamps its own scripts — then that render goes into the full-route cache with the nonce baked in, so every later visitor gets 96 tags carrying a dead nonce. Under 'strict-dynamic' a stale nonce does not degrade, it blocks: a blank page until the entry expires. The request CSP is stripped instead, and any pre-existing nonce attribute is removed before stamping. · Rewriting the upstream Host to loopback breaks redirects. Next builds absolute redirect URLs from Host, so `/` answered `Location: http://localhost:41337/en`. The Host is forwarded unchanged, and a redirect pointing at our own loopback is collapsed to a path as insurance. · Compression had to move. Upstream is asked for identity so the HTML can be rewritten without a decompress/recompress round trip, and the wrapper re-compresses — brotli where the client takes it, which is 30,177 bytes against the 56,324 gzip served before. scripts/check-csp-nonce.mjs holds the contract end to end on every build: boots the built app behind the wrapper and asserts the header carries a nonce and 'strict-dynamic', that EVERY script tag carries a nonce, that all of them are that response's, and that it rotates between requests. It caught the cached-nonce bug on its first run. A fixed nonce would satisfy the audit while being readable by anyone, so the rotation check is the point rather than a formality. Memory request 128Mi -> 256Mi: HTML is buffered to be stamped, so peak memory now scales with concurrent HTML requests instead of sitting flat.

All changes

Like what you see shipping?

Every one of these updates lands in your workspace automatically. Start free and watch it grow week after week.

Start Free ForeverView Pricing