Back to News & Insights
Web Development August 27, 2026 · 5 min read

The CSP that killed our own form (a production-only Next.js bug)

A user wrote to us with the kind of bug report that makes your stomach drop: "The forgot-password...

The CSP that killed our own form (a production-only Next.js bug)

So we did what you do. Checked the error tracker: clean. Checked the server logs: clean. Checked the audit log that records every password-reset request: zero requests, ever. The endpoint worked perfectly from curl. The page returned a healthy 200. Every test in a 3,500-test suite was green.

Here's the story of why — and why this class of bug is invisible in development, invisible in CI, and invisible to every server-side monitor you own.

The setup: a CSP we were proud of Our site ships a strict Content-Security-Policy. No unsafe-inline for scripts — instead, every response gets a fresh cryptographic nonce, stamped into the header by middleware:

Next.js plays along beautifully: it reads the nonce out of the script-src directive and stamps it onto every script tag it injects for hydration. Browser sees matching nonces, scripts run, page hydrates. This is the textbook setup — a security-scanner company can hardly ship unsafe-inline.

Next.js only injects the nonce on pages that are rendered dynamically — per request.

The trap: one page quietly went static In the Next.js App Router, pages that don't read request-time data get statically prerendered at build time. That's normally a gift: free speed. You can see which is which in the build output:

See it? Our /forgot-password page — a page with a form, a bot-check widget, client-side validation — was the one interactive page in the whole build that went static. Its HTML was baked once at build time. And at build time, there is no request, so there is no nonce.

Result: the page shipped script tags with no nonce at all, into responses whose CSP header demanded one.

With zero JavaScript executing: React never hydrated. No component ever mounted. The bot-check widget never rendered. It's loaded by script. The submit handler never attached. So the button fell back to what a button inside a does natively: submit-and-reload. Click → page flickers → same page. To a user, that is precisely "not working at all." No request ever left the browser. Which is why every server-side signal we had — logs, error tracker, audit trail — was spotless. You cannot log a request that was never made.

And the cruelest part: next dev renders everything dynamically. The bug cannot exist in development. curl doesn't execute JavaScript. The page 200s all day. Unit tests don't run a browser against the production build with the production CSP. The only place this bug existed was the one place we weren't looking: a real browser, pointed at the production build, with the console open.

The two-minute diagnosis (once we looked in the right place) Opening the production page with DevTools showed a wall of red CSP violations. Then one comparison nailed it — count nonced scripts per page:

Cross-checked against the build route table: /forgot-password had the ○ (static) marker. Its sibling /reset-password was dynamic — because someone had once added the magic line there and not on the twin page.

But a one-line fix for a silent, production-only, whole-page failure deserves more than one line. Two things we added: A regression test that names the failure class, not the instance: A QA rule we now follow on every release: at least one pass through the production deployment, in a real browser, with the console open. A page can return 200, look pixel-perfect, and be completely dead. Your server cannot tell you about requests that never happen — only the browser knows.

Takeaways Your security controls are part of your attack surface — against yourself. A strict CSP is worth it, but every hardening measure needs a test that proves the product still works under it. Static vs. dynamic rendering is a security-relevant decision in the App Router, not just a performance one. If your CSP uses nonces, an interactive page that goes static is broken by construction. Absence of errors is not evidence of health. Every monitoring signal we had was green while 100% of users hit a dead form. The failure lived client-side, before the first request. Read your build output. That little ○/ƒ column is a security audit nobody performs.

We build Webcuris(https://webcuris.com/) — continuous security assessment for websites and repos: CSP, TLS, headers, dependencies, and whether last month's fix actually held. This bug shipped in our own product, which is exactly why we believe in re-checking everything, continuously. You can scan one page free, no signup. (https://webcuris.com/scan)

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation