<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mayank Jain</title>
    <description>The latest articles on DEV Community by Mayank Jain (@mayank_jain_a705d6aacfd58).</description>
    <link>https://dev.to/mayank_jain_a705d6aacfd58</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2194698%2Fc97f6590-7dc3-4b75-a4a3-eefdd058709a.png</url>
      <title>DEV Community: Mayank Jain</title>
      <link>https://dev.to/mayank_jain_a705d6aacfd58</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mayank_jain_a705d6aacfd58"/>
    <language>en</language>
    <item>
      <title>The CSP that killed our own form (a production-only Next.js bug)</title>
      <dc:creator>Mayank Jain</dc:creator>
      <pubDate>Thu, 27 Aug 2026 09:21:18 +0000</pubDate>
      <link>https://dev.to/mayank_jain_a705d6aacfd58/the-csp-that-killed-our-own-form-a-production-only-nextjs-bug-4hil</link>
      <guid>https://dev.to/mayank_jain_a705d6aacfd58/the-csp-that-killed-our-own-form-a-production-only-nextjs-bug-4hil</guid>
      <description>&lt;p&gt;A user wrote to us with the kind of bug report that makes your stomach drop:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;"The forgot-password form is not working at all."&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Not "it's slow." Not "I got an error." Not working at all.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;And the form was completely dead in production.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: a CSP we were proud of
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;csp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildCsp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// script-src 'nonce-...' 'strict-dynamic' ...&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-nonce&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-security-policy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-security-policy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;csp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;There is just one sentence in the fine print, and it bit us:&lt;/p&gt;

&lt;p&gt;Next.js only injects the nonce on pages that are rendered dynamically — per request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: one page quietly went static
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;├ ○ /forgot-password      ← static  (prerendered at build time)
├ ƒ /login                ← dynamic (rendered per request)
├ ƒ /reset-password       ← dynamic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Result: the page shipped script tags with no nonce at all, into responses whose CSP header demanded one.&lt;/p&gt;

&lt;p&gt;The browser did exactly what we had asked it to do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;*&lt;em&gt;Every script on the page: blocked. By our own security header.&lt;br&gt;
*&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why "not working at all" was the perfect description
&lt;/h2&gt;

&lt;p&gt;With zero JavaScript executing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React never hydrated. No component ever mounted.&lt;/li&gt;
&lt;li&gt;The bot-check widget never rendered. It's loaded by script.&lt;/li&gt;
&lt;li&gt;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."&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the cruelest part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;next dev&lt;/code&gt; renders everything dynamically. The bug cannot exist in development.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt; doesn't execute JavaScript. The page 200s all day.&lt;/li&gt;
&lt;li&gt;Unit tests don't run a browser against the production build with the production CSP.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The two-minute diagnosis (once we looked in the right place)
&lt;/h2&gt;

&lt;p&gt;Opening the production page with DevTools showed a wall of red CSP violations. Then one comparison nailed it — count nonced scripts per page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// in the console, on each page:&lt;/span&gt;
&lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scripts&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;
&lt;span class="c1"&gt;// /login:            15  ✅&lt;/span&gt;
&lt;span class="c1"&gt;// /pricing:          19  ✅&lt;/span&gt;
&lt;span class="c1"&gt;// /forgot-password:   0  ❌  ← there's your dead page&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one line, plus the part that actually matters
&lt;/h2&gt;

&lt;p&gt;The one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/forgot-password/page.tsx&lt;/span&gt;

&lt;span class="c1"&gt;// Under a nonce CSP, a statically prerendered page ships un-nonced&lt;/span&gt;
&lt;span class="c1"&gt;// scripts and the browser blocks all of them. This line is load-bearing.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;force-dynamic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rebuild: ○ becomes ƒ, nonces flow, widget renders, form lives.&lt;/p&gt;

&lt;p&gt;But a one-line fix for a silent, production-only, whole-page failure deserves more than one line. Two things we added:&lt;/p&gt;

&lt;p&gt;*&lt;em&gt;1. A regression test that names the failure class, not the instance:&lt;br&gt;
*&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no auth page may be statically prerendered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Under a nonce CSP, a static page is a dead form waiting&lt;/span&gt;
  &lt;span class="c1"&gt;// for a user to find it.&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;forgot-password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reset-password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; forces dynamic rendering`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`app/(auth)/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/page.tsx`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`export const dynamic = "force-dynamic"`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. A QA rule we now follow on every release:&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Read your build output. That little ○/ƒ column is a security audit nobody performs.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;We build Webcuris(&lt;a href="https://webcuris.com/" rel="noopener noreferrer"&gt;https://webcuris.com/&lt;/a&gt;) — 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. (&lt;a href="https://webcuris.com/scan" rel="noopener noreferrer"&gt;https://webcuris.com/scan&lt;/a&gt;)&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>security</category>
      <category>debugging</category>
      <category>nextjs</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
