The header most AI-generated apps forget: Content-Security-Policy
By Seaworthy · 7 August 2026
Photo by Zulfugar Karimov on Unsplash
Ask most teams how they defend against XSS and the answer stops at one line: user input gets sanitized and escaped before it's rendered. That's necessary, and most frameworks now make it the default rather than something you have to remember. It's also not sufficient on its own, because it only defends the injection points you knew to think about. Content-Security-Policy is the header that defends the ones you didn't, and it's one of the more commonly missing headers in apps scaffolded quickly, AI-assisted or otherwise.
What CSP actually does that sanitization doesn't
Escaping user input stops an attacker from turning their input into a <script> tag in the first place. It's a defense at the point where untrusted data meets the page. CSP operates somewhere else entirely: it's a response header that tells the browser, unconditionally, which sources of script, style, and other resources are allowed to execute or load on this page, regardless of how a disallowed one got there.
The distinction matters because sanitization has to be correct at every single injection point, forever, across every template, every component, every third-party library that touches the DOM, every future contributor who adds a new one. Miss a single spot, a dangerouslySetInnerHTML call that skips the sanitizer, a template engine with autoescaping disabled for one field, a markdown renderer that allows raw HTML by default, and that one spot is a full XSS vulnerability. A properly configured CSP doesn't care where the injection happened or which code path missed the sanitizer. If the resulting inline script isn't from an allowed source, the browser refuses to run it, full stop, as a second, independent layer that doesn't depend on the first one having been implemented correctly everywhere.
Why it gets skipped
Setting CSP correctly requires enumerating every legitimate source of script, style, image, font, and connection your page actually uses, your own origin, an analytics provider, a font CDN, a payment widget's iframe, a WebSocket endpoint for real-time features. Miss one, and the failure mode isn't a warning in a log somewhere. It's the browser silently refusing to load that resource, which in the worst case means a blank white page with a console full of Refused to execute inline script because it violates the following Content Security Policy directive and no obvious link between that message and "the analytics snippet stopped loading."
That failure mode is worse-looking, in the moment, than having no CSP at all. No CSP means everything just works, all the time, with no header-driven mystery breakage to debug. Under any kind of deadline, "ship without the header that might break things" reliably beats "ship with the header and spend an afternoon finding out which third-party script it silently blocked", so it gets deferred, and deferred security work has a way of never coming back around.
A starting policy that doesn't require getting everything perfect on day one
You don't need a fully locked-down policy to get real value. A CSP that blocks unknown inline scripts and unknown external script sources already closes off the exact mechanism most XSS payloads rely on, even in report-only mode where it logs violations without blocking anything yet:
// middleware.ts (Next.js)
import { NextResponse } from "next/server";
export function middleware(request) {
const response = NextResponse.next();
response.headers.set(
"Content-Security-Policy",
[
"default-src 'self'",
"script-src 'self'", // no unsafe-inline, no unsafe-eval
"style-src 'self' 'unsafe-inline'", // many CSS-in-JS setups need this; tighten with nonces later
"img-src 'self' data: https:",
"connect-src 'self'",
"frame-ancestors 'none'", // also stops clickjacking
].join("; ")
);
return response;
}
That policy is intentionally not exhaustive, it doesn't yet account for a specific analytics domain or a payment provider's iframe, and it will need those added as script-src/frame-src entries once you know what the app actually loads. The point isn't to arrive at a perfect policy immediately. It's that script-src 'self' with no unsafe-inline already means an attacker-injected <script>alert(document.cookie)</script> anywhere on the page, regardless of which validation gap let it through, does not execute, because inline scripts aren't in the allowed source list at all.
For legitimate inline scripts you can't avoid (some analytics snippets, some framework hydration code), a nonce is the correct tool rather than falling back to unsafe-inline for the whole policy:
const nonce = crypto.randomUUID();
response.headers.set(
"Content-Security-Policy",
`script-src 'self' 'nonce-${nonce}'`
);
// and the script tag itself carries the matching nonce attribute
unsafe-inline and unsafe-eval are both easy to reach for when a CSP first breaks something, and both effectively cancel out most of what a script-src restriction was protecting against, they're the two directives worth treating as a last resort rather than a quick fix.
Run it in Content-Security-Policy-Report-Only mode first if you're retrofitting this onto an existing app. It logs what the real policy would have blocked without actually blocking anything, which turns "find out in production" into "find out from the report endpoint before you ever enforce it."
Seaworthy's security checks flag responses served without a Content-Security-Policy header at all, and policies that include unsafe-inline or unsafe-eval in script-src, the same class of gap as the other missing security headers already covered in the six check categories, and one of the more consequential ones to leave unset.
This article was generated by AI and summarises publicly available sources.
Seaworthy scans your repo for the issues covered in articles like this one.
Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.