Guides / add-security-headers-vercel-netlify-lovable
How to Add Security Headers to Your Vibe-Coded App (Vercel, Netlify, Lovable)
What each security header does in plain words, copy-paste configs for Vercel, Netlify and Cloudflare Pages, and what to do when your builder's hosting won't let you set them.
Why headers matter (and why your AI didn't add them)
Security headers are short instructions your server sends with every page. They tell the browser which scripts it may run, whether other sites may embed your app in a frame, and whether it must always use HTTPS. They don't fix bugs in your code, but they make whole classes of attacks (cross-site scripting, clickjacking, downgrade attacks) much harder.
Lovable, Bolt, Cursor and v0 generate app code. Headers are set by the host that serves the built files, so no change to your React components will add them. That's why so many vibe-coded apps ship with none.
What each header does
- Content-Security-Policy (CSP): an allow-list of where scripts, styles, fonts, images and network requests may come from. If an attacker manages to inject a
<script>tag, the browser refuses to run it unless its source is on the list. The most powerful header, and the one most likely to break things. - X-Frame-Options / frame-ancestors: stops other sites from loading your app inside an invisible iframe and tricking users into clicking buttons (clickjacking).
X-Frame-Options: DENYis the older header; the CSP directiveframe-ancestors 'none'is the modern one and wins when both are present. UseSAMEORIGIN/'self'if you frame your own pages. - Strict-Transport-Security (HSTS): tells the browser "only ever talk to this domain over HTTPS" for a set time, so nobody on café Wi-Fi can downgrade a visitor to plain HTTP. Vercel and Netlify already send a basic HSTS header on HTTPS domains; set it yourself only to change the value. Add
includeSubDomainsonly if every subdomain serves HTTPS, and don't addpreloadunless you understand that removal from the preload list is slow. - X-Content-Type-Options: nosniff: tells the browser to trust the declared file type instead of guessing. It stops an uploaded "image" from being run as a script.
- Referrer-Policy: controls how much of the current URL is sent to other sites when users click a link or load a third-party resource.
strict-origin-when-cross-originsends only your domain to other sites, so tokens or IDs in query strings don't leak. - Permissions-Policy (optional): switches off browser features you don't use, such as camera, microphone and location, for your page and any embedded iframes. Don't disable a feature your app actually needs.
A starter CSP (test it first)
This policy fits a typical Vite + React + Supabase app that uses Google Fonts. Replace YOUR-PROJECT with your Supabase project ref.
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://YOUR-PROJECT.supabase.co; connect-src 'self' https://YOUR-PROJECT.supabase.co wss://YOUR-PROJECT.supabase.co; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
Warning: a strict CSP will break anything it doesn't list. Common misses: Supabase Realtime (needs the wss:// entry), Supabase Storage images, analytics scripts and their beacon endpoints (for example Google Analytics needs googletagmanager.com in script-src and its collection domains in connect-src), Stripe.js (https://js.stripe.com in script-src and frame-src), inline scripts pasted into index.html, and custom fonts. Ship it first as Content-Security-Policy-Report-Only: the browser logs violations in the DevTools console without blocking anything. Click through sign-up, login, payments and uploads, add the origins you actually need, then rename the header to Content-Security-Policy.
Vercel: vercel.json
Common for Cursor and v0 apps. Put vercel.json in the project root (next to package.json) and redeploy. Next.js projects can use headers() in next.config.js instead.
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://YOUR-PROJECT.supabase.co; connect-src 'self' https://YOUR-PROJECT.supabase.co wss://YOUR-PROJECT.supabase.co; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" },
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "Strict-Transport-Security", "value": "max-age=63072000" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }
]
}
]
}
Moving a single-page app (like a Lovable or Bolt export) to Vercel? Add "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] to the same file so deep links don't 404. Real files are still served first.
Netlify: netlify.toml
Put this in netlify.toml at the repo root:
[[headers]]
for = "/*"
[headers.values]
Content-Security-Policy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://YOUR-PROJECT.supabase.co; connect-src 'self' https://YOUR-PROJECT.supabase.co wss://YOUR-PROJECT.supabase.co; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
X-Frame-Options = "DENY"
Strict-Transport-Security = "max-age=63072000"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Permissions-Policy = "camera=(), microphone=(), geolocation=()"
Netlify or Cloudflare Pages: _headers file
Both read a plain-text _headers file from the published output folder. In a Vite project, save it as public/_headers so the build copies it into dist. Each header line is indented under the path pattern.
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://YOUR-PROJECT.supabase.co; connect-src 'self' https://YOUR-PROJECT.supabase.co wss://YOUR-PROJECT.supabase.co; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
X-Frame-Options: DENY
Strict-Transport-Security: max-age=63072000
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
On both hosts these rules apply to static files. Responses from functions or server-rendered pages must set their own headers.
Lovable and other hosted builders
As far as we can tell, Lovable's built-in publishing does not offer a setting for custom response headers, and we're not aware of a hidden one. Asking the Lovable AI to "add security headers" may create a vercel.json or _headers file, but Lovable's own hosting won't read it. Your realistic options:
- Deploy the same code to Vercel or Netlify. Connect your project to GitHub, import the repo on Vercel or Netlify (build command
npm run build, output folderdistfor a Vite app), add one of the configs above, and point your custom domain there. - Put a proxy in front. If your domain runs through Cloudflare's proxy, a Response Header Transform Rule can add the headers to every response. Check first that your host supports a proxied custom domain.
- Partial fallback: a CSP meta tag. A
<meta http-equiv="Content-Security-Policy" content="...">tag inindex.htmlstill restricts scripts and connections, but browsers ignoreframe-ancestorsand report-only mode in meta tags, and HSTS, X-Frame-Options and nosniff can't be set this way.
The same logic applies to Bolt, Replit and others: find out which host actually serves your app. If it's Netlify or Vercel, the files above work. If it's the builder's own hosting and there's no header setting, use one of the options above.
Check that it worked
After redeploying, check the live custom domain, not just the preview URL:
curl -sI https://your-domain.com | grep -iE 'content-security|x-frame|strict-transport|x-content-type|referrer-policy|permissions-policy'
-I sends a HEAD request. If a host answers HEAD differently, use curl -s -D - -o /dev/null https://your-domain.com to see the headers from a normal GET. Then open the app in a browser with DevTools open and look for CSP errors in the console.
Or skip the terminal: the free PreflightVibe scan flags missing CSP, HSTS, X-Frame-Options and nosniff headers, alongside leaked keys and open Supabase tables. See what our security checker for vibe coded apps covers.
FAQ
- Can I add security headers inside my React or Vite code?
- No. Headers come from whatever serves your files. The only partial exception is a CSP meta tag, which can't set
frame-ancestorsor report-only mode. - Will a Content-Security-Policy break my app?
- It can. Start with
Content-Security-Policy-Report-Only, test every flow, add the origins you need, then enforce. - Do I need both X-Frame-Options and frame-ancestors?
- Modern browsers follow
frame-ancestorswhen both are present. Sending both is harmless and covers older browsers. - Does Vercel or Netlify already send HSTS?
- Yes, on HTTPS domains. Set it yourself only to change the value.
- How do I check which headers my site sends?
- Run
curl -sI https://your-domain.com, or paste your URL into the free PreflightVibe scan.
Check your headers with a free PreflightVibe scan
Paste your live URL. In about 30 seconds you get an A–F grade covering headers, leaked keys, exposed files and Supabase access. Unlock the full report with fixes for $29 when you are ready.
Run the free scan