BuildNot yet confirmed elsewhere1 publisher3 min readPublished
A strict CSP killed one form in production, and every monitor stayed green
A per-request nonce met a statically prerendered page, and the browser refused every script it was sent. Six health signals, including a 3,500-test suite, reported fine.
The Engineer · Build desk

What happened
- Middleware stamped a fresh nonce into a script-src carrying strict-dynamic, with no unsafe-inline for scripts.
- The build route table showed /forgot-password prerendered at build time while /login and /reset-password rendered per request.
- The browser refused to execute main-app.js, layout.js and every other chunk on that page, citing the site's own policy.
Why it matters
- cost The bill was paid by users who could not reset a password, with a support email as the only detector, for as long as that audit log had existed.
- constraint Monitoring keyed to requests cannot cover this class at all, because the failure completes before anything is sent; the instrument has to be a browser running the production bundle under the...
- exposure Every interactive route that the build marks as prerendered is exposed the same way, which makes the route table, not the dashboard, the list of pages that might already be dead.
- decision Leaving the fix as a per-file export means each new page is another chance to forget it, so the choice is now between an author's memory and a check that runs on the build.
A nonce is worth something only because it is minted per request, and a page baked at build time has no request to mint from, so the framework has nothing to stamp onto the script tags it writes [5][6]. It does not fail loudly at that point. It emits the tags bare, and the middleware still attaches a header demanding a nonce to the response carrying them [15]. With no unsafe-inline for scripts anywhere in the policy, an un-nonced tag had nothing else to match against, so the browser blocked everything from main-app.js down [14][16].
The invisibility comes from what the HTML does next. React never hydrated, so no component mounted; the bot-check widget, which arrives by script, never drew, and the submit handler never attached [17]. A button inside a form with no handler still does its native job, which is submit and reload, so the user got a flicker and the same page back [18]. No request of any kind left the browser [19].
Six health signals were green, and every one of them samples the server: the error tracker, the server logs [1], the password-reset audit trail [2], curl against the endpoint, the page's 200 [3], and 3,500 passing tests [4][21]. None of them can see a failure that never leaves the client. The audit log deserves a second look, though, because it does not say "no recent requests". It records every password-reset request and holds zero, ever, which means the form had produced nothing at all for the entire life of that log [2][22]. This was not something that broke last Tuesday.
Finding it took about twenty characters in a console. Counting scripts that carry a nonce attribute returned 15 on /login, 19 on /pricing, and 0 on /forgot-password [20]. The gap is not marginal: the lowest healthy count sits fifteen above the dead one, so "greater than zero" is a clean pass or fail for any route in the build [24].
The repair is a single export in the page file, dynamic set to force-dynamic [11]. Its sibling /reset-password already had that line, because someone added it there once and did not add it to the twin page [12]. That is the part worth being unhappy about. Under a nonce policy, whether a form works at all is decided by whether an author remembered a per-file annotation, and the build output already prints the answer: any route flagged as statically prerendered that also ships interactivity is a dead route waiting for a user to notice [23]. Reading that table costs nothing next to the thing that actually caught this, which was a real browser pointed at the production build with the production header and the console open [10].
What to watch
- Whether Next.js starts failing the build, rather than shipping un-nonced scripts, when a prerendered route is served under a nonce policy.
- Whether the nonced-script count becomes a post-deploy smoke check run in a real browser against the production build and header.
- Whether the per-page force-dynamic annotation is replaced by a build-time rule covering every interactive route.
Clarity's read
What the record supports and how the coverage leans. The claims behind it follow.
Reality
- Evidence60
- Adoption20
- Hype gap+8
- Incentives30
- Confidence55
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
The team checked the error tracker and the server logs after the report; both were clean.
- [2]
The audit log that records every password-reset request showed zero requests, ever.
- [3]
The endpoint worked perfectly from curl and the page returned a healthy 200.
- [4]
Every test in a 3,500-test suite was green.
- [5]
Next.js reads the nonce out of the script-src directive and stamps it onto every script tag it injects for hydration, but only on pages that are rendered dynamically, per request.
- [6]
In the Next.js App Router, pages that do not read request-time data are statically prerendered at build time, and at build time there is no request and therefore no nonce.
- [7]
The build route table marked /forgot-password as static (prerendered at build time) while /login and /reset-password were marked dynamic (rendered per request); /forgot-password was the one interactive page in the whole build that went static.
- [8]
next dev renders everything dynamically, so the bug cannot exist in development.
- [9]
curl does not execute JavaScript, so the page returns 200 regardless.
- [10]
Unit tests do not run a browser against the production build with the production CSP; the only place the bug existed was a real browser pointed at the production build with the console open.
- [11]
The fix was one line in app/forgot-password/page.tsx: export const dynamic = "force-dynamic".
- [12]
The sibling page /reset-password was dynamic because someone had once added that line there and not on the twin page.
- [13]
A user reported that the forgot-password form was "not working at all", and the form was completely dead in production.
- [14]
The site ships a strict Content-Security-Policy with no unsafe-inline for scripts; middleware generates a fresh cryptographic nonce per response and builds a CSP whose script-src contains that nonce plus strict-dynamic.
- [15]
The statically prerendered page shipped script tags with no nonce at all, inside responses whose CSP header demanded one.
- [16]
The browser refused to execute _next/static/chunks/main-app.js, app/layout.js and every other chunk on the page, citing the Content-Security-Policy directive; every script on the page was blocked by the site's own security header.
- [17]
With no JavaScript executing, React never hydrated, no component ever mounted, the script-loaded bot-check widget never rendered, and the submit handler never attached.
- [18]
Without a handler, the button fell back to the native behaviour of a button inside a form, submit-and-reload: the click made the page flicker and return the same page.
- [19]
No request ever left the browser, which is why every server-side signal the team had was spotless; you cannot log a request that was never made.
- [20]
Running [...document.scripts].filter(s => s.nonce).length in the console returned 15 on /login, 19 on /pricing and 0 on /forgot-password.
- [21]
Six distinct health signals reported the system as fine while the form was dead: error tracker, server logs, password-reset audit log, curl, HTTP 200, and the test suite.
- [22]
Because the audit log records every password-reset request and contains zero entries ever, the form produced no submissions at all for the entire lifetime of that log, so the failure was not a recent regression.
- [23]
Since nonce injection only happens on per-request renders and the build table labels which routes are prerendered, any route marked static that also ships interactive scripts will serve un-nonced scripts that the policy blocks.
- [24]
The lowest healthy nonced-script count is 15 above the failing page's count of 0, so a threshold of greater-than-zero separates working from dead routes across the sampled pages.
Sources
1 independent publisher whose own reporting we read for this story.
- dev.toThe CSP that killed our own form (a production-only Next.js bug)
1 article · August 27, 2026
Topics and entities
Follow any of these and your For You feed starts watching them — no settings page required.