technical-essay · 2026-09-07 · 5 min read
A Nonce Is Not a Security Upgrade If Your Pages Are Static
How a textbook-perfect Content-Security-Policy shipped a site with every script blocked, while every gate stayed green
Every hardening guide says the same thing about Content-Security-Policy: 'unsafe-inline' in script-src is the weak link, and the way to remove it is a per-request nonce. It is good advice. It is also, on a statically prerendered site, a way to ship a page where no JavaScript runs at all — and to have every check you own tell you it went fine.
I did this deliberately, measured it, and reverted it. This is what happened and how to tell in advance whether it applies to you.
The change
The site had a reasonable policy with one soft spot:
script-src 'self' 'unsafe-inline'
The standard fix is middleware that mints a fresh nonce per request, puts it on the response header, and lets the framework stamp the same value onto every script tag it emits:
const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))));
const csp = `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; ...`;
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('Content-Security-Policy', csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('Content-Security-Policy', csp);
Setting it on the request headers matters: that is the channel Next.js reads to discover the nonce and apply it to its own bootstrap scripts. Miss that and nothing works. I did not miss it.
Every gate passed
TypeScript: clean. ESLint: clean. Unit tests: all passing. next build: exit 0, middleware registered in the route table. Then the response itself, which looked like something from a conference talk:
content-security-policy: script-src 'self' 'nonce-t9iUvkID2R168JDcHvJUAg==' 'strict-dynamic';
default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:;
frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
Exactly one CSP header. A fresh nonce on every request — I checked two requests returned different values. No 'unsafe-inline' in script-src. By every artifact I could inspect, the hardening had landed.
The measurement that mattered
The header is only half the contract. The other half is whether the script tags in the HTML carry that same nonce. So I counted them — script tags in the served HTML carrying the nonce from that same response:
| Route | Rendering | Scripts with the nonce |
|---|---|---|
/ |
static | 0 of 11 |
/about |
static | 0 of 10 |
/contact |
static | 0 of 11 |
/search |
dynamic | 10 of 10 |
One route worked. Twenty-four did not.
Why
A nonce is a per-request value. Statically prerendered HTML is written once, at build time, and served unchanged to everyone. Those two facts cannot both hold. The build has no request to mint a nonce for, so the HTML it writes contains no nonce, and no header sent later can retroactively put one there.
'strict-dynamic' then finishes the job. It tells the browser to ignore host allowlists and trust only scripts carrying the nonce. On a static route that is every script on the page. The browser does exactly what it was told and refuses all of them.
The dynamic route worked because it is rendered per request, so the nonce exists at render time and gets stamped in. That contrast is the whole diagnosis: the mechanism is fine, the architecture is incompatible.
Why nothing caught it
This is the part worth internalising, because the failure is invisible to the entire conventional toolchain:
- The build cannot see it. The HTML is valid. The middleware compiles. There is no error to raise.
- The header is correct. Anything inspecting response headers reports success.
- Type checking and linting are irrelevant. No type expresses "this nonce will reach the markup."
- Unit tests do not cover it. They test modules, not the interaction between a rendering strategy and a browser policy.
The defect exists only in the browser, only at runtime, and only as an absence — scripts that never execute. A page with no interactive JavaScript often looks fine on first paint, which is precisely how this reaches production.
What to do instead
If your routes are statically prerendered, a nonce is not available to you. The real options:
- Keep
'unsafe-inline'forscript-srcon static routes. Unsatisfying, honest, and no worse than before. Constrain what actually carries risk:object-src 'none',base-uri 'self',frame-ancestors 'none',form-action 'self'. - Move to build-time hashes.
'sha256-...'for each inline script. Compatible with static output because a hash is a property of content, not of a request. The cost is regenerating hashes whenever the framework's inline bootstrap changes. - Force every route dynamic. Technically works. For a content site this trades away static delivery — sub-100ms TTFB from an edge cache — for a marginal policy improvement. Rarely the right trade.
The general lesson
The rule I took from this is narrower and more useful than "test in a browser":
When a security control depends on a value that must appear in two places, verify it in both. A correct header proves the server's half of the contract. It says nothing about the document's half.
Nonce CSP is one instance. Subresource Integrity is another — the attribute can be perfect while the fetched asset changed. So is any Permissions-Policy you have never actually exercised.
The build tells you what you compiled. The header tells you what you sent. Only the browser tells you what happened, and on this defect the browser is the only witness that disagrees.