A user wrote to us with the kind of bug report that makes your stomach drop:
"The forgot-password form is not working at all."
Not "it's slow." Not "I got an error." Not working at all.
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.
And the form was completely dead in production.
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:
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const csp = buildCsp(nonce); // script-src 'nonce-...' 'strict-dynamic' ...
const headers = new Headers(request.headers);
headers.set("x-nonce", nonce);
headers.set("content-security-policy", csp);
const response = NextResponse.next({ request: { headers } });
response.headers.set("content-security-policy", csp);
return response;
}
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.
There is just one sentence in the fine print, and it bit us:
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:
├ ○ /forgot-password ← static (prerendered at build time)
├ ƒ /login ← dynamic (rendered per request)
├ ƒ /reset-password ← dynamic
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.
The browser did exactly what we had asked it to do:
Refused to execute script '.../_next/static/chunks/main-app.js'
because it violates the following Content Security Policy directive: ...
Refused to execute script '.../_next/static/chunks/app/layout.js' ...
Refused to execute script ... (× every single chunk)
*Every script on the page: blocked. By our own security header.
*
Why "not working at all" was the perfect description
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 devrenders everything dynamically. The bug cannot exist in development. -
curldoesn'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:
// in the console, on each page:
[...document.scripts].filter(s => s.nonce).length
// /login: 15 ✅
// /pricing: 19 ✅
// /forgot-password: 0 ❌ ← there's your dead 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.
The fix: one line, plus the part that actually matters
The one line:
// app/forgot-password/page.tsx
// Under a nonce CSP, a statically prerendered page ships un-nonced
// scripts and the browser blocks all of them. This line is load-bearing.
export const dynamic = "force-dynamic";
Rebuild: ○ becomes ƒ, nonces flow, widget renders, form lives.
But a one-line fix for a silent, production-only, whole-page failure deserves more than one line. Two things we added:
*1. A regression test that names the failure class, not the instance:
*
describe("no auth page may be statically prerendered", () => {
// Under a nonce CSP, a static page is a dead form waiting
// for a user to find it.
for (const page of ["login", "signup", "forgot-password", "reset-password"]) {
it(`${page} forces dynamic rendering`, () => {
const source = readFileSync(`app/(auth)/${page}/page.tsx`, "utf-8");
expect(source).toContain(`export const dynamic = "force-dynamic"`);
});
}
});
2. 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)
Top comments (0)