Content Security Policy for Vibe-Coded Apps: A Practical CSP Guide
CSP is the strongest defense-in-depth against XSS, but AI generators rarely ship a real one. Here is what to keep, what to remove, and how to roll it out safely.
By Gabriel CA · Kraftwire Software
· 10 min readKey takeaway
Content Security Policy (CSP) is a browser-enforced allowlist that decides which scripts, styles, and other resources your page is allowed to load and run. It's the strongest defense-in-depth against cross-site scripting (XSS) · even if an attacker sneaks malicious markup into your page, a good CSP can stop it from executing. AI code generators almost never ship a real CSP, and when they do, it usually contains one setting (`'unsafe-inline'`) that quietly cancels most of the protection. This guide shows you what to keep, what to remove, and how to roll it out without breaking your app.
What CSP actually does
CSP is an HTTP response header (or a `<meta>` tag) that tells the browser: only load resources from these sources, and refuse everything else. The browser · not your server, not your framework · enforces it. When a page tries to run a script or load an image that the policy doesn't allow, the browser blocks the request and, optionally, sends you a report.
The policy is a list of directives, each naming a resource type and its allowed sources:
Content-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self' data:
This says: by default only load from your own origin (`'self'`), only run scripts from your own origin, and allow images from your origin plus inline `data:` URIs. Anything else · a script injected by an attacker, a pixel from an unknown domain · gets blocked before it does damage.
That's the whole point. XSS works by getting the browser to execute attacker-controlled JavaScript. A CSP that only allows scripts from sources you control means an injected `<script>alert(document.cookie)</script>` never runs, even if your input sanitization failed. Sanitization is your first line of defense; CSP is the net underneath it.
Why vibe-coded apps get this wrong
AI generators optimize for "it works in the preview," and a strict CSP can break inline scripts and styles that the generated code relies on. So the tools take the path of least resistance. Here are the patterns we see over and over when scanning Lovable, Cursor, Bolt.new, v0, and Replit apps.
No CSP at all
The most common case. The header simply isn't set, so the browser applies no restrictions. Any successful injection runs with full privileges. This is the default state of most AI-generated apps unless you or your host add a policy.
script-src 'unsafe-inline'
This is the quiet killer. `'unsafe-inline'` tells the browser to allow any inline `<script>` block and inline event handlers like `onclick="..."`. That is exactly the mechanism most XSS attacks use. Including `'unsafe-inline'` in `script-src` defeats the primary reason CSP exists · you get a policy that looks protective in the header but stops almost nothing. If a scanner grades your policy and it has this, treat the grade as "no real script protection."
script-src 'unsafe-eval'
`'unsafe-eval'` re-enables `eval()`, `new Function()`, and similar string-to-code paths. Some older libraries need it, but it hands attackers a way to turn any string they control into executing code. Remove it unless a specific dependency genuinely requires it · and if one does, consider replacing that dependency.
Wildcard * and bare https: sources
A directive like `script-src 'self' https:` or `script-src *` allows scripts from any HTTPS site or literally anywhere. That means an attacker only needs to point a script tag at their own domain, which your policy happily permits. Sources should be specific hostnames you actually use, not open-ended wildcards.
Missing object-src 'none'
The `<object>`, `<embed>`, and `<applet>` elements can load plugin content that bypasses your script rules. Almost no modern app needs them. Setting `object-src 'none'` closes an entire legacy attack surface for free.
Missing base-uri
The `<base>` tag rewrites how relative URLs resolve. If an attacker injects a `<base href="https://evil.example">`, your relative script paths can suddenly load from their server. Setting `base-uri 'self'` (or `'none'`) prevents this, and it's a directive that `default-src` does not cover.
Missing frame-ancestors (clickjacking)
`frame-ancestors` controls who can embed your page inside an iframe. Without it, an attacker can frame your app on their own site, overlay it with invisible buttons, and trick users into clicking things they can't see · clickjacking. Setting `frame-ancestors 'none'` (or listing the origins you trust) blocks it. This directive is the modern replacement for the older `X-Frame-Options` header.
No default-src fallback
`default-src` is the catch-all that applies to any resource type you didn't name explicitly · `connect-src`, `font-src`, `media-src`, and more. Without it, every unlisted directive falls back to "allow everything." A strict `default-src 'self'` means new resource types are locked down by default instead of wide open.
The modern alternative to 'unsafe-inline': nonces and hashes
The reason generators reach for `'unsafe-inline'` is that real apps often do have some inline scripts. The correct fix isn't to allow all inline scripts · it's to allow the specific ones you trust. Two mechanisms do this.
A nonce is a random value you generate fresh on every response. You put it in the header and on each trusted script tag:
Content-Security-Policy: script-src 'nonce-r4nd0m123'
<script nonce="r4nd0m123">/* your trusted inline code */</script>
The browser runs only inline scripts carrying the matching nonce. An attacker who injects a script can't guess the per-request value, so their code is blocked. The nonce must be unpredictable and regenerated every request · a hardcoded nonce is worthless.
A hash is the alternative when your inline script content is static. You compute a SHA-256 hash of the exact script contents and list it: `script-src 'sha256-...'`. The browser runs the inline script only if its hash matches. Hashes need no per-request server logic, which makes them a good fit for static builds.
One important note: when you use a nonce or hash, modern browsers ignore `'unsafe-inline'` if it's also present · so keeping it as a "fallback" doesn't reopen the hole for CSP Level 2+ browsers. Still, drop `'unsafe-inline'` to keep the policy honest and readable.
Roll it out safely with report-only mode
Tightening a CSP on a live app risks breaking legitimate scripts you forgot about. That's what report-only mode is for. Instead of `Content-Security-Policy`, send:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reports
In this mode the browser does not block anything · it only reports what would have been blocked. You run your app normally, collect the violations, fix the real sources, and only then switch the header to the enforcing name. It's the difference between finding your gaps in a log and finding them in a support ticket.
A reasonable starter policy
This is a solid baseline for a typical vibe-coded single-page app. Adjust the specific sources to match the services you actually call, and add a nonce or hash for any inline scripts you genuinely need.
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
connect-src 'self' https://your-project.supabase.co;
object-src 'none';
base-uri 'self';
frame-ancestors 'none'
Note what's not here: no `'unsafe-inline'`, no `'unsafe-eval'`, no wildcards. Start in report-only mode, watch for violations, then enforce.
How to deploy the header
You have two options.
The HTTP response header is the recommended path. Most hosts and platforms let you set response headers · Netlify and Vercel via a config file, Cloudflare via a rule or Worker, and Supabase edge functions by adding the header to the response. The header form supports every directive, including `frame-ancestors`, and it's set before the page renders.
The `<meta>` tag is the fallback when you can't control response headers:
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; object-src 'none'">
It works for most directives but has real limits · `frame-ancestors` and report-only mode are ignored in meta form, and it only applies after the HTML starts parsing. Use the header when you can, and the meta tag only when you truly can't.
Check your policy, then check your whole app
CSP has a lot of moving parts, and a single wrong token can silently gut the protection. Paste your policy into our free [CSP Evaluator](/tools/csp-evaluator) · it gives you an A–F grade with specific fixes, runs 100% client-side, and never sends your policy anywhere. It's one of several free security tools at our [free tools hub](/tools).
For the fuller picture, CSP is one layer among several. Pair it with [CSRF protection and security headers](/blog/csrf-security-headers-guide) and a solid [XSS prevention guide](/blog/xss-prevention-guide) so your sanitization and your CSP reinforce each other instead of relying on either alone.
When you're ready to see every gap at once · missing headers, weak CSP, exposed secrets, and more · run a full scan of your app at simplyscan.io. It takes a minute, and it's built specifically for apps that were vibe-coded, not hand-audited.