June 6, 2026
Chasing Content Security Policy (CSP) in NextJS
From Security Headers to Rendering Architecture
Roshan Pratap Katel
10 min read
A few months ago, the Security Department reached out to our team with a seemingly straightforward request: "SecurityHeaders.com has graded our website as F."
The explanation was simple enough. Our application was missing several HTTP security headers, and because of that the scanner considered our security posture weak.
As is usually the case with topics that cross my path, I took the opportunity to learn about security headers rather than simply implement them. Tasks come and go, but the knowledge stays. I learned about:
- HSTS
- Referrer Policy
- Permissions Policy
- X-Frame-Options
- X-Content-Type-Options
- Content Security Policy (CSP)
One by one, I added them to the Next.js application and watched these headers appear in the 'Response Headers' section in the browser. All the headers were straightforward to implement but one—Content Security Policy. Unlike the others, CSP wasn't something I could simply switch on and forget. It required a deep understanding of every script, asset, and external resource the application depended on. Anyone who has opened this can of worms knows how deceptive that simplicity is.
What started as an effort to turn an F into an A eventually led me into browser security, CSP reporting storms, nonces, static site generation, and architectural constraints.
The Easy Victories
To build a proper Content Security Policy, I needed visibility into what the application was actually doing in the browser. So I enabled CSP in report-only mode and wired up an endpoint to collect violation reports. The idea was simple: let the application run freely, observe everything it complains about, and gradually build a complete whitelist of scripts, assets, and external resources.
For the rest of the security headers, they were already close to where they needed to be, requiring only minor adjustments.
Once everything was in place, I ran another scan on SecurityHeaders.com. And just like that — boom. The score jumped.
We went from an embarrassing F to a solid A.
All of it. Clean jump. The problem had been solved.
'A' came from other security headers except CSP. CSP was enabled only in report-only mode.
CSP Report-Only Strikes Back
Just as I was riding the high of the improved security score, a colleague pointed out something odd—our CSP reporting endpoint was getting hit at an alarming rate, and the function invocation had skyrocketed. The report-only mode, which was meant to quietly observe the application, was doing the opposite: flooding our server with violation reports on every page load. In practice, a single visit was triggering well over a hundred reports.
The intention behind CSP report-only is simple: gradually build a whitelist of scripts, assets, and external resources by observing real browser behaviour. But that only works if the system is introduced carefully, with a reasonably complete baseline. Otherwise, every small missing detail becomes a report — and at scale, that adds up fast.
Security controls like this are powerful, but they come with infrastructure cost. If left unchecked, this wasn't just observability — it was a self-inflicted DDoS attack on our own serverless function.
At that point, the only practical option was to roll back the report-only setup entirely. CSP was removed for now, the rest of the headers stayed intact, and the A-grade security score remained.
But the problem wasn't really gone. It was just postponed.
The Seduction of A+
Content Security Policy was not something I was willing to leave unfinished. Besides being one of the most important security headers, it had become a challenge I could no longer ignore—I had been defeated by CSP already once.
At this point, my colleague Ivan joined me on the quest. He spent a lot of time refining the CSP allow-list, identifying scripts, assets, and external resources used throughout the application. Together, we repeatedly ran CSP in report-only mode against preview environments, collected violation reports, adjusted the policy, and repeated the process until the reports eventually dried up.
With a reasonably comprehensive allow-list in place, we became curious: What would happen if we enforced the policy?
So we did.
We deployed the policy to a preview environment, ran another scan on SecurityHeaders.com, and waited for the verdict.
The Content Security Policy was finally green. Yet the overall grade remained stuck at A.
After digging into the report, the reason became clear: our policy still allowed unsafe-inline and unsafe-eval. That was the moment the goal changed. Up until then, the objective had been to implement CSP correctly. Now there was a second objective: understand what unsafe-inline and unsafe-eval actually were, why they were considered dangerous, and whether we could get rid of them.
And, if I'm being honest, the promise of that alluring A+ wasn't making it any easier to walk away. Hence, the real adventure began there.
What on earth is unsafe-inline?
At this point, I had never really given much thought to unsafe-inline.
I had seen it in examples before. I had copied it into CSP policies before. And honestly, I suspect many developers do the same thing I did: add it because something breaks without it and move on with life. But now it was standing between us and both a stricter CSP policy and that alluring A+ rating. So, I started digging.
The first thing I learned was that unsafe-inline is closely tied to what are known as inline scripts—JavaScript written directly inside an HTML document rather than loaded from a separate JavaScript file.
<script>
console.log("Hello World");
</script><script>
console.log("Hello World");
</script>Without CSP, browsers happily execute such scripts. In fact, the web has worked this way for decades. At first glance, this seems completely harmless.
The problem is not the script itself. The problem is that browsers cannot easily distinguish between a script written by a developer and a script injected by an attacker. Imagine a website that allows users to leave comments. If the application fails to properly sanitize user input, an attacker might submit something like:
<script>
stealCookies();
</script><script>
stealCookies();
</script>The browser has no idea whether this script came from the application's source code or from a malicious user. It simply sees JavaScript and executes it. This type of attack is commonly known as Cross-Site Scripting (XSS), and it is one of the primary reasons Content Security Policy exists in the first place.
A useful way to think about unsafe-inline is to imagine leaving the front door of your house unlocked because you trust the neighborhood. Most days nothing bad happens. Your friends can come and go freely, and life is convenient.
The problem is that the unlocked door doesn't just welcome your friends. It welcomes everyone. The browser sees unsafe-inline in a CSP policy in much the same way. By allowing inline scripts, we are essentially telling the browser:
"Don't worry too much about where this JavaScript came from. Just run it."
From a security perspective, that is a very uncomfortable instruction. This is why a CSP policy that contains unsafe-inline is considered weaker than one that does not. The browser loses one of its strongest defenses against injected scripts and XSS attacks.
And this was exactly the reason SecurityHeaders.com refused to award us an A+.
The question was now obvious: If unsafe-inline weakens CSP and increases exposure to XSS attacks, why was it showing up everywhere Next.js seemed to rely on it. Google Tag Manager seemed to rely on it. Countless production websites seemed to rely on it. Surely all of these platforms and teams weren't ignoring security best practices.
So the next question wasn't whether unsafe-inline was risky. The next question was why modern web applications appear to need it in the first place — and whether there was a way to get rid of it without breaking everything.
The Revelation of Nonce
At this point, we knew unsafe-inline had to go. And we also knew there had to be a better way. So we started looking for alternatives, and naturally — like most engineering rabbit holes — it began with asking the right tool. Between us, we leaned on Gemini quite a bit during this phase, mostly to sanity-check ideas and surface possible approaches we hadn't considered yet.
That's where we first saw it: nonce.
Next.js documentation describes it clearly: a nonce is a one-time-use random value that lets you safely allow specific inline scripts while keeping strict CSP rules in place.
Instead of allowing all inline scripts with unsafe-inline, CSP can be configured to only execute scripts that include a matching nonce value.
So the browser behavior becomes very simple:
If the script has the correct nonce → allow it If it doesn't → block it
And the security model is equally simple: An attacker cannot predict the nonce for the current request. Without that value, injecting a valid script becomes effectively useless.
At that point, it felt like the missing piece had finally appeared. We immediately started implementing it.
We removed unsafe-inline from the CSP script directive and began wiring nonce generation through middleware. The plan was straightforward: generate a fresh nonce per request, pass it through the request lifecycle, and attach it to the relevant script tags in the application.
We tested locally first. No CSP violations. No blocked scripts. Everything loaded cleanly. That was the moment of confidence.
We had removed unsafe-inline. The policy was strict. The application still worked.
We pushed the changes to a preview environment and ran the scan on SecurityHeaders.com. A few moments later, the result came back, and it was A+.
That was it. No warnings. No missing pieces. No compromises flagged.
It felt like the entire CSP journey had finally resolved itself into a single outcome. The kind of result you look at once, lean back, and think — done.
Wait… Why is the site Broken?
That feeling didn't last long. We opened the preview link expecting everything to be stable. To our dismay, it wasn't.
The site was partially broken. Some pages didn't load correctly. Navigation behaved unpredictably. And the browser console was suddenly full of CSP violations.
What caught our attention immediately was the _next/chunks scripts being blocked. These weren't custom scripts. These were core framework assets — things the application absolutely depended on.
At first, this didn't make sense. We were confident the CSP allowlist was complete. We had iterated on it carefully. Nothing major should have been missing. But CSP was now behaving differently than expected. Something fundamental was off.
Architecture: The Real Enemy
This is where the real understanding started to form. The issue wasn't just CSP configuration. It was the mismatch between how we were trying to secure the application and how the application was actually being rendered.
We had built a nonce-based CSP system assuming everything could be dynamically wired at request time. But most parts of the application were not request-time at all. They were statically generated. And that's where the contradiction became unavoidable.
A nonce, by definition, is a "number used once" — generated per request, per response, per session of interaction. But static generation works differently. Pages are produced at build time and reused across requests. There is no per-request execution context to inject a fresh nonce into the HTML before it is served.
That created the core problem: Middleware runs at request time. SSG/ISR runs at build time.
Two different execution models. Two incompatible assumptions.
Or put more directly: You cannot attach a "number used once" to something that is generated only once and reused for everyone.
At that point, it wasn't a CSP problem anymore. It was a rendering architecture problem — and in a way, we had hit the limit of the framework itself.
The Tumor Named unsafe-inline
At this point, the conclusion was becoming difficult to ignore. We could remove unsafe-inline. We had already proven that. The problem was everything that came with it.
Our nonce-based approach worked precisely because it relied on request-time generation. But parts of our application rely on static generation and ISR, and those rendering strategies are not something we were willing to sacrifice in pursuit of a stricter CSP policy.
This is where engineering becomes interesting. Sometimes the decision is obvious: there is a good solution and a bad solution. But many real-world decisions are not like that. Sometimes the choice is between the ideal solution and the practical solution.
From a pure CSP perspective, removing unsafe-inline would be preferable. From a product and architectural perspective, preserving static generation and ISR was equally important.
So we chose the middle ground.
Instead of abandoning CSP altogether, we enforced it through next.config.js and continued using unsafe-inline and unsafe-eval where necessary. The policy still provides meaningful protection by restricting where scripts, assets, and resources can be loaded from. It is not as strict as the nonce-based approach, but it is significantly better than having no CSP at all. This was also a useful reminder that security scanners and security reality are not always the same thing.
SecurityHeaders.com was correct to point out that unsafe-inline and unsafe-eval weaken the policy. But engineering decisions rarely happen in isolation. They exist within the constraints of frameworks, rendering strategies, performance requirements, operational costs, and business needs.
For now, unsafe-inline remains. Not because we do not understand the risk. Not because we did not find a solution. But because every solution comes with trade-offs, and this was the trade-off that made the most sense for our application.
The Future
Although we eventually settled on a practical compromise, the journey left me with far more questions than answers.
In particular, it made me curious about where CSP is heading and how modern frameworks are trying to reconcile strong security policies with increasingly complex rendering architectures.
One area I have been exploring is CSP Level 3 and some of the mechanisms it introduces to move beyond broad allowances such as unsafe-inline.
Nonces are one such mechanism. They provide a way to explicitly trust individual scripts rather than trusting all inline scripts. As I discovered, however, nonces fit naturally into dynamic rendering models and become significantly more challenging when static generation enters the picture.
Hashes are another interesting approach. Rather than attaching a nonce to a script, the browser can be instructed to trust a script whose content matches a specific cryptographic hash. In theory, this can work well for static content because the hash can be calculated ahead of time.
More recently, I have also been reading about Sub-resource Integrity (SRI). While SRI is not a replacement for CSP, it addresses a related concern by allowing browsers to verify that external resources have not been modified since the application was built. The browser downloads the resource, computes its hash, and compares it with the expected value. If they do not match, the resource is rejected.
What makes this particularly interesting is that newer versions of Next.js are beginning to explore stronger support for these security mechanisms. While I have not yet implemented or evaluated them in production, they represent an area I intend to keep a close eye on. And perhaps that is the biggest lesson from this entire exercise.
The goal was never really to get an A+. The A+ was simply the excuse.
The real value came from understanding how browsers enforce trust, how modern frameworks generate pages, and where those two worlds sometimes collide.
This particular ticket is now closed, but the curiosity remains!
A special thank you to Ivan Jevtovic, whose persistence and curiosity kept this CSP journey moving forward whenever I was tempted to stop digging.