Back to blog

The Header Your AI App Is Missing (And Why It Matters)

Content Security Policy is the single most powerful client-side defense against XSS — and AI coding tools never add it. Here's exactly how to build a strict CSP in Next.js and Express, from first line to full enforcement.

June 1, 20268 min read
csp
content-security-policy
nextjs
xss

Header image

A friend of mine runs a moderately successful SaaS — a few thousand users, steady MRR, nothing flashy. Last month, he woke up to a message from a customer: "Hey, I think someone's sending weird messages from my account." He checked the logs. A script had been injected into a comment field six hours earlier. For six hours, every visitor to that page had been running attacker-controlled JavaScript in their browser.

The injection point was a <textarea> that rendered user input without sanitization. Classic XSS. But what bothered him more than the bug itself was this: if he'd had any form of Content Security Policy, the injected script would have been blocked instantly. The browser would have refused to execute it. Six hours of compromise, prevented by one HTTP header he didn't know existed.

Open your vibe-coded app. Open DevTools → Network. Reload. Do you see Content-Security-Policy anywhere in the response headers? If not, every XSS vulnerability in your app is a direct path to your users' browsers. No guardrails. No safety net.

CSP tells the browser exactly which sources of content are allowed. Scripts from your domain? Yes. Scripts from a random domain the attacker injected? No. Even if an attacker finds an XSS vector, the browser refuses to execute their payload. It's the last line of defense — and it works.

Start Strict, Then Loosen

The right way to build a CSP is the wrong way to keep your app working. You start with nothing allowed, then add sources one at a time as things break. Here's the starting point:

Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'

Figure 2: The strict starting point — blocks everything by default, forcing you to explicitly allow each source.

Deploy this and your app will almost certainly break. That's intentional. Every broken script, missing stylesheet, and failed API call is a dependency you didn't know your app had. Fix them by adding sources — one at a time, never in bulk:

style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.yourapp.com; img-src 'self' https://cdn.yourapp.com data:;

Figure 3: Adding sources one at a time — each addition is a conscious trust decision for a specific domain.

Each addition is a conscious decision: "I trust this domain to serve content into my users' browsers." Don't add sources you don't recognize. Don't add wildcards. Don't add https: because it's easier than listing specific domains — that's like locking your front door and leaving the window wide open.

Next.js Middleware with Nonces (The Gold Standard)

Nonces are random tokens generated per-request. Scripts must carry a matching nonce to execute. Combined with 'strict-dynamic', a nonced script can load additional scripts without you needing to list every CDN domain in your policy.

// 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 csp = [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self'", "connect-src 'self'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", ].join('; '); const response = NextResponse.next(); response.headers.set('Content-Security-Policy', csp); // Pass nonce to your app so inline scripts can use it response.headers.set('X-Nonce', nonce); return response; } export const config = { matcher: '/((?!_next/static|_next/image|favicon.ico).*)', };

Figure 4: Next.js middleware with per-request nonces and strict-dynamic — the gold standard for CSP in Next.js.

'strict-dynamic' is the secret weapon. It says: "any script loaded by an already-trusted script is also trusted." This means you don't need to list Google Analytics, Stripe, or any other third-party script host in your policy — as long as your own script loads them, they're automatically trusted. It eliminates the maintenance burden of tracking every CDN domain.

To use the nonce in your pages, read the X-Nonce header from middleware and apply it to <script> tags:

// In a Server Component import { headers } from 'next/headers'; export default function Page() { const nonce = headers().get('X-Nonce') || ''; return ( <> <Script src="https://third-party.com/widget.js" nonce={nonce} /> </> ); }

Figure 5: Server Component reading the nonce from middleware and applying it to a third-party script tag.

Express with Helmet

If you're on Express, Helmet gives you CSP without the boilerplate:

const helmet = require('helmet'); app.use(helmet.contentSecurityPolicy({ directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", "data:", "https:"], fontSrc: ["'self'"], connectSrc: ["'self'", "https://api.yourapp.com"], frameAncestors: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], }, }));

Figure 6: Express CSP via Helmet — declarative directives for each content type without the raw header string.

You can generate nonces in Express too:

const crypto = require('crypto'); app.use((req, res, next) => { res.locals.nonce = crypto.randomBytes(16).toString('base64'); next(); }); app.use(helmet.contentSecurityPolicy({ directives: { scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`, "'strict-dynamic'"], }, }));

Figure 7: Express nonce generation middleware — creates a random nonce per request for strict-dynamic CSP.

The Report-Only Safety Net

Don't deploy an enforcing CSP straight to production. Use report-only mode first:

Content-Security-Policy-Report-Only: default-src 'none'; ...

Figure 8: Report-Only CSP — logs violations without blocking, letting you test safely before enforcing.

Load every page. Click every button. Submit every form. Check the browser console for [Report Only] violations. Each violation tells you exactly which source the browser tried to load and was blocked. Add sources for legitimate content. Fix code that's doing things it shouldn't.

Once violations are clean, switch to the enforcing header. This process takes about 30 minutes for a typical app. If you skip report-only, you'll break your app in ways you won't notice until a user complains.

Common CSP Pitfalls

PitfallWhat HappensThe Right Way
'unsafe-inline' in script-srcDisables CSP's XSS protection entirely. Any inline script executes.Use nonces or hashes for legitimate inline scripts
'unsafe-eval' without thoughtAllows eval() — common XSS vector. Some frameworks (Vue, React dev builds) need itRemove unsafe-eval in production. If you must keep it, add a report-uri to monitor
Skipping report-only modeEnforcing CSP breaks production because you missed a dependencyAlways deploy Report-Only first, monitor violations for 24 hours, then enforce
Forgetting frame-ancestorsYour app can be iframed for clickjacking, even with CSP presentAlways include frame-ancestors 'none' (or replace with X-Frame-Options: DENY)
Using data: in script-srcAttackers can encode scripts in data URIs and inject them. data: in script-src is a known bypassNever allow data: in script-src. Use it only in img-src if needed
Logging violations without actingYour report-uri endpoint fills with data but nobody reviews it. You're blind to ongoing attacksSet up alerts on your report endpoint. Weekly review of violation reports

Figure 1: Common CSP pitfalls, what goes wrong, and the correct approach for each.

The Payoff

My friend spent a weekend building his CSP. He started with default-src 'none', broke his app three times, added sources one at a time, ran report-only for a day, then flipped to enforcement. Two weeks later, a script injection attempt hit his comment field — the same vector that had compromised him before. The browser blocked it. The CSP report endpoint logged the attempt. His users never knew anything happened.

A properly configured CSP is the difference between "we have an XSS vulnerability" and "we had an attempted XSS attack that was blocked." Thirty minutes of configuration for a security control that works silently, 24/7, in every user's browser.


Summary

Found this useful? Scan your app at vibeshield.org — we check for CSP headers and flag unsafe directives like 'unsafe-inline' and data: in script-src.

Read next: 5 Security Headers Every Vibe-Coded App Needs — CSP is the heavy hitter, but there are four more headers you need. Also Cookie Security: Your Session Token Is One Flag Away from Theft — CSP and HttpOnly cookies work together to keep your users safe.