Your CSP Is Probably Useless: The unsafe-inline Epidemic
Your CSP Is Probably Useless: The unsafe-inline Epidemic
Content-Security-Policy is the most important security header on the web. It's your primary defense against cross-site scripting (XSS) — the vulnerability class that has topped the OWASP Top 10 for over a decade. A properly configured CSP tells the browser exactly which scripts are allowed to execute on your page.
But here's what we found in our benchmark data: of the projects that actually set a CSP header, the majority include unsafe-inline in their script-src directive. Which means their CSP is doing almost nothing.
Projects with a CSP that includes unsafe-inline: Formbricks, Supabase, Directus, Typebot, and others. These are well-maintained projects with real security teams. They went through the effort of adding CSP — and then immediately undermined it.
What unsafe-inline actually does
A Content-Security-Policy without unsafe-inline tells the browser: "Only execute scripts loaded from approved sources. Ignore any JavaScript embedded directly in the HTML."
This is the entire point of CSP. XSS attacks work by injecting script tags or inline event handlers into your page. If the browser refuses to execute inline scripts, the injected code is dead on arrival.
When you add 'unsafe-inline' to your script-src, you're telling the browser: "Execute inline scripts too." Which means an attacker who can inject <script>stealCookies()</script> into your page has a working attack again.
A CSP with unsafe-inline is like a lock with the key taped to the door. Technically present. Functionally useless for its primary purpose.
Why projects add it
Nobody adds unsafe-inline because they want weaker security. They add it because things break without it, and the breakage is immediate and visible.
Third-party scripts
Google Analytics, Intercom, Segment, HubSpot — most third-party services inject inline scripts. Their installation instructions literally tell you to paste a <script> tag into your HTML. Without unsafe-inline, these scripts are blocked and the service stops working.
CSS-in-JS
Libraries like styled-components, Emotion, and MUI inject <style> tags at runtime. Without unsafe-inline in style-src, your entire app renders unstyled. This is particularly common in React ecosystems where CSS-in-JS is the default styling approach.
Framework behavior
Some frameworks inject inline scripts for hydration data, environment variables, or module loading. Next.js, for example, inlines __NEXT_DATA__ as a script tag. Without special handling, removing unsafe-inline breaks hydration.
Code editors and rich text
Projects that embed Monaco (VS Code's editor), CodeMirror, or any rich-text editor often need unsafe-inline because the editor evaluates user input as code. Typebot and similar tools fall into this category.
The pattern is always the same: developer adds CSP, something breaks, developer adds unsafe-inline to fix it, security benefit evaporates.
The fix: nonces
The correct approach is nonce-based CSP. Instead of allowing all inline scripts, you generate a unique random token (nonce) for each request and only allow inline scripts that include that specific nonce.
Content-Security-Policy: script-src 'nonce-abc123def456' 'strict-dynamic'
An inline script will only execute if it has the matching nonce:
<!-- This executes -->
<script nonce="abc123def456">
console.log('allowed');
</script>
<!-- This is blocked — no nonce, even though it's inline -->
<script>
console.log('blocked');
</script>
An attacker who injects a script tag can't know the nonce (it changes every request), so their injected script is blocked. That's the security model working as intended.
Implementing nonces in Next.js
Next.js 13+ supports nonce-based CSP through middleware. Here's a working implementation:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`.replace(/\s{2,}/g, ' ').trim()
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', cspHeader)
response.headers.set('x-nonce', nonce)
return response
}
Then in your layout, read the nonce and pass it to Script components:
// app/layout.tsx
import { headers } from 'next/headers'
import Script from 'next/script'
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const nonce = (await headers()).get('x-nonce') ?? undefined
return (
<html lang="en">
<body>
{children}
<Script
src="https://www.googletagmanager.com/gtag/js"
nonce={nonce}
strategy="afterInteractive"
/>
</body>
</html>
)
}
The strict-dynamic escape hatch
The 'strict-dynamic' directive is the key to making nonce-based CSP practical. It means: "If a trusted script (one with a valid nonce) loads another script dynamically, trust that script too."
This solves the third-party script problem. Your Google Analytics loader has a nonce, so it's allowed to execute. When it dynamically loads its tracking library, strict-dynamic allows that too — without you needing to whitelist every CDN domain Google might use.
What our audit checks
The Security Headers & Basics audit has two separate checks for CSP:
security-headers.headers.csp-present(severity: high) — Is there a CSP header at all?security-headers.headers.csp-no-unsafe-inline(severity: high) — Does the CSP avoidunsafe-inlineinscript-src?
Both are weighted as high-severity. A project can pass the first check and fail the second — which is exactly the unsafe-inline epidemic pattern. Having CSP without unsafe-inline requires both checks to pass.
In our benchmark data, roughly 40% of projects have some form of CSP. Of those, most include unsafe-inline. The number of projects with a properly restrictive CSP — one that actually prevents XSS — is in the single digits.
The uncomfortable truth
Implementing nonce-based CSP is genuinely harder than adding most security headers. It's not a one-line fix. It requires middleware changes, layout updates, and testing every page to ensure nothing breaks. Third-party scripts need nonces. CSS-in-JS libraries need configuration changes. It's a real engineering task.
But that's what makes it valuable. X-Content-Type-Options: nosniff is a one-liner that prevents MIME-type sniffing — a relatively minor attack vector. A properly configured CSP prevents XSS — the most common and most exploited vulnerability class on the web. The effort-to-impact ratio is strongly in CSP's favor.
If your project has a CSP with unsafe-inline, you have two options:
- Remove the CSP entirely. At least then you're not creating a false sense of security.
- Do the work to implement nonce-based CSP properly.
Option 2 is better. But option 1 is more honest than a CSP that doesn't do what CSP is supposed to do.
Run the check
The Security Headers & Basics audit is free. It will tell you whether your CSP exists, whether it includes unsafe-inline, and what to do about it. The audit runs in your AI coding tool and takes about 15 minutes.
Most projects that run it discover they're in the unsafe-inline club. Now you know how to leave it.