Guide · Headers · Vercel · Netlify · Cloudflare · Replit · Updated
Security headers for vibe-coded apps: CSP, HSTS and the rest, with configs we tested
Most hosts hand you HTTPS and stop there. Security headers are a handful of lines your server sends with every page, telling the browser how to protect the people using your app. Apps built with Lovable, Bolt, v0, Replit or Cursor often ship with few or none of them, and the AI that wrote the app rarely brings them up. This is the page we wanted when we set them up for LockShip: what each header does, values you can copy, where they go on each host, and a starter Content Security Policy that doesn’t break Supabase, Firebase, Stripe or Google Fonts. We tested every config here in Chrome before writing it down.
New to this? Start with our vibe coding security checklist, eleven checks for any AI-built app. Headers are check 7 there. This page goes deep on that one check.
Why bother
A missing header won’t leak your database. The things that do that, like open Supabase tables or a secret key in your JavaScript, are covered in other guides and matter more. Headers are the layer behind them. If someone does get a script into your page, through a comment field, a bad npm package or a markdown renderer, the Content Security Policy decides whether that script can run and where it can send what it reads. MDN describes CSP as mainly a defence against cross-site scripting, and that is how we think about it: a seatbelt you hope you never need.
On 18 October, a builder on X who runs his own scanner posted that he had checked ten vibe-coded apps launched in the previous three weeks. None leaked API keys or .env files, but nine had no Content Security Policy and eight could be framed for clickjacking. Ten apps is a small sample, and we can’t check his work. It matches what we see when we read headers on apps people share, though.
The short version
These are the six headers worth sending, the values we’d start with, and whether the LockShip scan checks for each one. The values match the OWASP HTTP Headers Cheat Sheet and the OWASP Secure Headers Project in spirit, softened where their strictest settings tend to break apps built on Supabase and Stripe.
| Header | Start with | What it does | LockShip checks it |
|---|---|---|---|
| Content-Security-Policy | the starter policy below, report-only first | Tells the browser which scripts, styles and connections your page may use, so an injected script has a much harder time running or sending data out. | Yes: present or missing. It doesn’t grade what’s inside. |
| Strict-Transport-Security | max-age=63072000; includeSubDomains | Tells the browser to use only HTTPS for your domain from now on. | Yes: present or missing. It doesn’t check max-age. |
| X-Content-Type-Options | nosniff | Stops the browser from guessing a file’s type and running something as a script that wasn’t meant to be one. | Yes |
| X-Frame-Options and frame-ancestors | DENY, plus frame-ancestors 'none' in the CSP | Stops other sites from loading your app in a hidden frame and tricking people into clicking. | Yes: either one counts. |
| Referrer-Policy | strict-origin-when-cross-origin | Keeps full URLs, which can hold tokens and IDs, from being sent to other sites. | Yes: present or missing. |
| Permissions-Policy | camera=(), microphone=(), geolocation=() | Turns off browser features your app doesn’t use, so injected code can’t use them either. | No |
If you build with an AI tool and just want it done, paste this into its chat along with the starter policy further down. Then read the rest of this page before you switch the policy to enforcing.
Add security headers to this app for every page: Content-Security-Policy-Report-Only (not the enforcing header yet), Strict-Transport-Security "max-age=63072000; includeSubDomains" with no preload, X-Content-Type-Options "nosniff", X-Frame-Options "DENY", Referrer-Policy "strict-origin-when-cross-origin" and Permissions-Policy "camera=(), microphone=(), geolocation=()". Start the CSP from the policy below and add the exact hosts for the services this app really calls from the browser. Tell me which file you changed and which hosts you added, and don't change anything else.Read your own headers first
It takes a minute, and it tells you what your host already does for you. From a terminal:
curl -sI https://YOUR-APP.com | grep -iE "^(content-security-policy|strict-transport-security|x-frame-options|x-content-type-options|referrer-policy|permissions-policy|x-powered-by)"The -I asks for headers only. Every line that comes back is a header your site sends; a missing line is a header it doesn’t. Here is what we saw on lockship.dev while writing this:
content-security-policy: default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-src 'self' https://checkout.stripe.com; form-action 'self' https://checkout.stripe.com; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests
permissions-policy: camera=(), microphone=(), geolocation=()
referrer-policy: strict-origin-when-cross-origin
strict-transport-security: max-age=63072000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: DENYIf you don’t have a terminal, use the browser. Open your live app in Chrome, open DevTools with F12, go to the Network tab and reload. Click the first row, which is your page itself, and read Response Headers. Keep DevTools open on the Console tab while you work through this page: that is where the browser tells you what a policy would block. Check the live address your users visit, not a preview, because the headers can differ.
A few things we saw when we looked at fresh apps on each host. Vercel sends HSTS for you: its encryption docs say .vercel.app addresses get max-age=63072000; includeSubDomains; preload and custom domains get max-age=63072000 for that exact subdomain. Next.js adds X-Powered-By: Next.js unless you turn it off. The Lovable and Cloudflare Pages apps we read sent nosniff and a Referrer-Policy but no CSP and no frame protection. Hosts change their defaults, so trust your own curl over this paragraph.
The six headers, one at a time
Content-Security-Policy
A CSP is a list of rules: scripts may come from here, styles from there, the page may connect to these addresses and nothing else. The MDN reference lists every directive. The ones that matter for a typical app are script-src, style-src, connect-src (where JavaScript may send requests), img-src, font-src, frame-src (what you may embed), and a few that close doors you never use: object-src 'none', base-uri 'self' and form-action 'self'.
The hard part is that an enforced CSP blocks everything you forgot to list. That is why it has a test mode. Content-Security-Policy-Report-Only takes the same policy, and MDN describes it as monitoring violations “without enforcing the security policies.” The browser logs every would-be block in the Console and blocks nothing. You can also have reports sent to a server with report-to, but for a small app the Console is enough.
One honest note on 'unsafe-inline' in script-src. It lets inline scripts run, and MDN’s CSP guide says it “defeats much of the purpose of having a CSP.” Next.js puts inline scripts in every page, so a Next.js app needs either 'unsafe-inline' or a nonce, which is a random value set fresh on every request. The Next.js CSP guide shows both. The starter below uses 'unsafe-inline' because it works on any page, including static ones. Even then, the policy still decides where scripts load from and where data can be sent. The nonce version is further down, and it’s better if your pages are rendered per request anyway. LockShip itself still uses 'unsafe-inline', because our pages are static.
Strict-Transport-Security (HSTS)
HSTS tells the browser to use only HTTPS for your domain, even if someone types http:// or clicks an old link. MDN notes that browsers ignore it when it arrives over plain HTTP, so it only counts on HTTPS responses. max-age=63072000 is two years. includeSubDomains extends it to every subdomain, so leave it out if you have a subdomain that can’t do HTTPS yet. We’d leave out preload; there is a section on why below.
X-Content-Type-Options: nosniff
The only value is nosniff. MDN says that with it, the browser blocks a script or stylesheet whose MIME type doesn’t match, instead of guessing. That matters if your app serves files people upload. It costs nothing, so send it.
Frame protection: frame-ancestors and X-Frame-Options
Clickjacking means another site loads your app in an invisible frame and gets your user to click a button they can’t see, like “delete account” or “pay.” The modern fix is frame-ancestors 'none' in your CSP. X-Frame-Options: DENY is the older header that does the same. OWASP’s cheat sheet says to use frame-ancestors if you can, and we send both, for one practical reason: while your CSP is in report-only mode, its frame-ancestors only reports and doesn’t protect anything. MDN also notes that frame-ancestors isn’t supported in a <meta> tag, and X-Frame-Options in a meta tag has no effect. Both have to be real headers.
Use SAMEORIGIN and frame-ancestors 'self' instead if your own app frames its own pages. If you want your app embedded on specific partner sites, list them in frame-ancestors and drop X-Frame-Options, since it can’t name other sites.
Referrer-Policy
When someone clicks a link from your app to another site, the browser can tell that site which page they came from. If your URLs hold things like a reset token or a record ID, that’s a leak. MDN says strict-origin-when-cross-origin is already the browser default: other sites only see your domain, not the full path. OWASP says to set it explicitly anyway rather than rely on defaults, and we agree, because then you know what you’re sending. Use no-referrer if you don’t want other sites to see even your domain.
Permissions-Policy
Permissions-Policy turns browser features on or off for your page and anything it embeds. camera=(), microphone=(), geolocation=() switches off the three that matter most, which is the example OWASP gives too. If your app does use one, say a map with “find me,” remove that entry or allow only your own site with geolocation=(self). MDN still marks the header as experimental, and browser support varies, so treat it as a small extra.
A starter CSP for Supabase, Stripe and Google Fonts
This is the policy in every config on this page. Here it is on its own lines so you can read it:
default-src 'self'
script-src 'self' 'unsafe-inline' https://js.stripe.com https://*.js.stripe.com
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com
font-src 'self' https://fonts.gstatic.com
img-src 'self' data: blob: https://*.supabase.co https://*.stripe.com
connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com
frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com
object-src 'none'
base-uri 'self'
form-action 'self'
frame-ancestors 'none'
upgrade-insecure-requestsHow we tested it: a Next.js 15.5 app with this policy in next.config, loading the Supabase client (a database query and a realtime channel), a Supabase storage image, Stripe.js with a mounted card field, Google Fonts through a <link> tag, a React inline style, and Vercel Web Analytics and Speed Insights. Headless Chrome 155 logged zero violations. Then we stripped the service hosts out, and the same page logged violations for every one of them, which is how you know the test can fail. We repeated it with the static-site version on Cloudflare Pages’ local server, with Express and Helmet, and with the nonce version on Next.js 15 and 16. All came back clean.
What each part is for:
- Stripe. The
js.stripe.com,hooks.stripe.comandapi.stripe.comentries come from the CSP section of Stripe’s integration security guide. hooks.stripe.com is for 3D Secure. If you use its Address Element with your own Google Maps key, Stripe says to addhttps://maps.googleapis.comto connect-src and script-src as well. - Supabase. Your Supabase client talks to
https://YOUR-REF.supabase.coand opens realtime connections onwss://at the same address.*.supabase.coallows every Supabase project, including one an attacker controls, so once it works, replace the wildcard with your own project address. If you use a custom Supabase domain, list that instead. - Inline styles.
'unsafe-inline'in style-src is there for React’sstyleprops and component libraries that inject CSS. When we tried a nonce in style-src instead, every inline style was reported. We keep the strictness for scripts, where it counts most. - upgrade-insecure-requests. MDN explains it makes the browser fetch any
http://resource over HTTPS. In report-only mode Chrome ignores it and says so in the Console. That message is expected and harmless.
What to add for other services
Add only what your app really calls from the browser. Your server-side calls don’t count, because CSP only governs what the page itself loads.
| Service | Add | Notes |
|---|---|---|
| Supabase (database, auth, realtime, storage) | connect-src https://YOUR-REF.supabase.co wss://YOUR-REF.supabase.co, and img-src https://YOUR-REF.supabase.co if you show Storage images | In the starter as *.supabase.co. Swap in your own project address once it works. |
| Firebase Auth and Firestore | See the block below | Not in the starter. Our test without these lines logged 38 violations. |
| Stripe.js and Elements | script-src and frame-src https://js.stripe.com https://*.js.stripe.com, frame-src https://hooks.stripe.com, connect-src https://api.stripe.com, img-src https://*.stripe.com | In the starter. Stripe lists these in its security guide. |
| Stripe Checkout, redirect | form-action https://checkout.stripe.com, if a form posts to your server and your server redirects to Checkout | Not in the starter. Chrome blocked a form-to-another-site redirect in our test without it. |
| Google Fonts with a <link> tag | style-src https://fonts.googleapis.com, font-src https://fonts.gstatic.com | In the starter. |
| next/font | Nothing | The fonts are served from your own domain. |
| Vercel Web Analytics and Speed Insights | Nothing on Vercel | In production they load from your own domain under /_vercel/. |
Firebase needs the most. We ran a Firebase app with Google sign-in, anonymous sign-in and a Firestore read against the starter policy, and Chrome reported the Google API script, the Identity Toolkit and Firestore requests, and the sign-in iframe on the project’s firebaseapp.com address. Firebase’s docs explain that iframe: the authDomain in your config “hosts the sign-in helper code.” With these additions the same test came back clean:
script-src ... https://apis.google.com
connect-src ... https://identitytoolkit.googleapis.com https://securetoken.googleapis.com https://firestore.googleapis.com
frame-src ... https://YOUR-PROJECT.firebaseapp.com
# Only if you use Storage or the Realtime Database
img-src ... https://firebasestorage.googleapis.com
connect-src ... https://firebasestorage.googleapis.com https://*.firebaseio.com wss://*.firebaseio.comUse your own authDomain for the frame-src line. securetoken is where the SDK refreshes sign-ins, and the Storage and Realtime Database hosts are the ones the SDK uses for those products; our test project never got far enough to call them, so we took them from the SDK’s source.
Stripe Checkout is the trap. A common setup is a form that posts to your server, which redirects to checkout.stripe.com. With form-action 'self', Chrome 155 blocked a form submission that redirected to another site in our test, and the button simply did nothing. If that’s how your checkout works, use this line instead. It’s what LockShip sends:
form-action 'self' https://checkout.stripe.comFonts and analytics. The next/font docs say fonts are served from your own deployment and “no requests are sent to Google by the browser,” so they need nothing extra. Vercel Web Analytics and Speed Insights load from /_vercel/ on your own domain in production. In local development they load from va.vercel-scripts.com, so you may see a report for that host on localhost. You can ignore it.
Where the headers go, host by host
Every block below sends the same six headers with the same starter policy. Use the one for where your app is served, not where it was built.
Next.js on Vercel, or anywhere (v0, Cursor, Bolt’s Next.js templates)
Next.js reads custom headers from the headers setting in next.config. This file also turns off X-Powered-By. The unsafe-eval line only applies in development, where the Next.js CSP guide says React needs it for better error messages. Merge it into the next.config you already have:
import type { NextConfig } from 'next'
const isDev = process.env.NODE_ENV === 'development'
// Starts in report-only mode. Rename the key to Content-Security-Policy
// once the browser Console stays quiet on every screen.
const csp = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ''} https://js.stripe.com https://*.js.stripe.com`,
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: blob: https://*.supabase.co https://*.stripe.com",
"connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com",
"frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
'upgrade-insecure-requests',
].join('; ')
const securityHeaders = [
{ key: 'Content-Security-Policy-Report-Only', value: csp },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
]
const nextConfig: NextConfig = {
poweredByHeader: false,
async headers() {
return [{ source: '/:path*', headers: securityHeaders }]
},
}
export default nextConfigWe ran exactly this file on Next.js 15.5.28 and 16.4.2. All six headers came back and Chrome logged nothing. On Vercel, your HSTS value takes the place of the platform’s default, which Vercel’s docs say you’re free to do.
The stricter version, with a nonce. If your pages already render per request, you can drop 'unsafe-inline' for scripts. A proxy file creates a random nonce for each request, Next.js reads it from the request’s CSP header and adds it to its own scripts, and 'strict-dynamic' lets those scripts load Stripe.js and the rest. This follows the Next.js guide, with our service hosts and report-only mode. On Next.js 16 the file is proxy.ts. On Next.js 15 it’s middleware.ts and the function is called middleware.
import { NextRequest, NextResponse } from 'next/server'
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const isDev = process.env.NODE_ENV === 'development'
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''}`,
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: blob: https://*.supabase.co https://*.stripe.com",
"connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com",
"frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
'upgrade-insecure-requests',
].join('; ')
// Next.js reads the nonce from the request's CSP header and adds it to its own scripts.
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
requestHeaders.set('Content-Security-Policy', csp)
const response = NextResponse.next({ request: { headers: requestHeaders } })
response.headers.set('Content-Security-Policy-Report-Only', csp)
return response
}
export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}Two things to know before you use it. First, remove the report-only CSP line from next.config, so you don’t send two policies. Second, the Next.js guide says nonces need dynamic rendering: static pages are built before any request exists, so they get no nonce, and that makes static optimization, ISR and Partial Prerendering unavailable. Reading headers in the root layout made every page dynamic in our test:
// app/layout.tsx: only needed with the nonce version
import { headers } from 'next/headers'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
await headers() // renders every page per request, so each one gets a fresh nonce
return (
<html lang="en">
<body>{children}</body>
</html>
)
}With 'strict-dynamic', browsers that support it ignore host lists in script-src, which is why that line has no Stripe address. The other directives still use their host lists.
Vercel without Next.js (Vite, React, plain HTML)
For a Vite app on Vercel, put a vercel.json next to your package.json. The vercel.json docs describe the headers setting, and this file passed validation against Vercel’s published schema. Vite builds its scripts into files, so this version doesn’t need 'unsafe-inline' for scripts. If the Console reports an inline script, find it before you add it back.
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy-Report-Only",
"value": "default-src 'self'; script-src 'self' https://js.stripe.com https://*.js.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://*.supabase.co https://*.stripe.com; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com; frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests"
},
{
"key": "Strict-Transport-Security",
"value": "max-age=63072000; includeSubDomains"
},
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
},
{
"key": "Permissions-Policy",
"value": "camera=(), microphone=(), geolocation=()"
}
]
}
]
}Netlify (and Bolt apps deployed to Netlify)
Netlify takes either a _headers file in the folder it publishes, or a [[headers]] block in netlify.toml. For a Vite app, put _headers in public/ so the build copies it into dist.
/*
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://js.stripe.com https://*.js.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://*.supabase.co https://*.stripe.com; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com; frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
Strict-Transport-Security: max-age=63072000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()[[headers]]
for = "/*"
[headers.values]
Content-Security-Policy-Report-Only = "default-src 'self'; script-src 'self' https://js.stripe.com https://*.js.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://*.supabase.co https://*.stripe.com; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com; frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests"
Strict-Transport-Security = "max-age=63072000; includeSubDomains"
X-Content-Type-Options = "nosniff"
X-Frame-Options = "DENY"
Referrer-Policy = "strict-origin-when-cross-origin"
Permissions-Policy = "camera=(), microphone=(), geolocation=()"Both parsed cleanly with Netlify’s own headers parser. One limit to know from Netlify’s headers docs: custom headers only apply to files Netlify serves directly. Pages rendered by a function or edge function, which includes server-rendered Next.js pages, don’t get them, and Netlify says the function should return the headers itself. For Next.js on Netlify, use the next.config block above.
Cloudflare Pages and Workers
Cloudflare Pages reads the same _headers format, so the Netlify file above works as it is. We served a built Vite app with it through Cloudflare’s local Pages server, and all six headers came back. The Pages headers docs allow up to 100 rules, each line up to 2,000 characters, and our CSP line is well under that. A Worker serving static assets supports a _headers file too, according to the Workers docs.
The same limit applies as on Netlify. Cloudflare says _headers rules aren’t applied to responses from Pages Functions, so a server-rendered framework has to set headers in its own code. If your domain is proxied through Cloudflare (orange cloud), a Response Header Transform Rule can set headers on any response, whoever generated it.
Replit
It depends on how you publish. A Static Deployment takes headers in the .replit file, as Replit’s docs describe, and you republish to apply them. Replit reserves a few headers, such as Server and Set-Cookie, but none of the six here.
# Add to .replit (Static Deployments only), then republish
[[deployment.responseHeaders]]
path = "/*"
name = "Content-Security-Policy-Report-Only"
value = "default-src 'self'; script-src 'self' https://js.stripe.com https://*.js.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://*.supabase.co https://*.stripe.com; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com; frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests"
[[deployment.responseHeaders]]
path = "/*"
name = "Strict-Transport-Security"
value = "max-age=63072000; includeSubDomains"
[[deployment.responseHeaders]]
path = "/*"
name = "X-Content-Type-Options"
value = "nosniff"
[[deployment.responseHeaders]]
path = "/*"
name = "X-Frame-Options"
value = "DENY"
[[deployment.responseHeaders]]
path = "/*"
name = "Referrer-Policy"
value = "strict-origin-when-cross-origin"
[[deployment.responseHeaders]]
path = "/*"
name = "Permissions-Policy"
value = "camera=(), microphone=(), geolocation=()"The TOML parses, but we didn’t publish a Replit app to watch it live, so check with curl after you republish. Apps Replit Agent builds often run an Express server on an Autoscale or Reserved VM deployment, and there the server sets its own headers. Helmet is the standard way to do it. Its defaults are a reasonable start, but its default CSP would block Stripe and Supabase, so here it is with the starter policy:
import express from "express";
import helmet from "helmet";
const app = express();
app.use(
helmet({
contentSecurityPolicy: {
reportOnly: true, // delete this line once the Console stays quiet
directives: {
"script-src": ["'self'", "https://js.stripe.com", "https://*.js.stripe.com"],
"style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
"font-src": ["'self'", "https://fonts.gstatic.com"],
"img-src": ["'self'", "data:", "blob:", "https://*.supabase.co", "https://*.stripe.com"],
"connect-src": ["'self'", "https://*.supabase.co", "wss://*.supabase.co", "https://api.stripe.com"],
"frame-src": ["https://js.stripe.com", "https://*.js.stripe.com", "https://hooks.stripe.com"],
"frame-ancestors": ["'none'"],
},
},
strictTransportSecurity: { maxAge: 63072000 },
xFrameOptions: { action: "deny" },
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
}),
);
app.use((req, res, next) => {
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
next();
});We ran this with Helmet 8.3 and Express 5 in front of the same test page. Every header came back and Chrome logged nothing. Helmet also sets X-XSS-Protection: 0, a few cross-origin headers, and removes X-Powered-By. Put these lines before your routes and static files, or those responses go out without the headers.
Lovable
This is where we have to be straight with you: we couldn’t find a way to set custom response headers in Lovable’s hosting docs. Lovable says hosting is “nothing to configure,” which is nice until you want to configure something. The Lovable apps we read sent HSTS, nosniff and a Referrer-Policy, but no CSP and no frame protection. You have two real options:
- Put Cloudflare in front. Lovable’s custom domain docs have an advanced option, Uses Cloudflare or similar proxy, that connects your domain with a CNAME so traffic goes through your Cloudflare account first. Then add a Response Header Transform Rule that sets the six headers. Lovable is clear that you are then responsible for the proxy setup. We haven’t run a Lovable app this way ourselves, so check the result with curl.
- Host it somewhere else. Lovable’s hosting and ownership page lists Netlify, Cloudflare Pages and Vercel as places you can host the frontend after syncing the code to GitHub. Then use that host’s block above.
You could also put a CSP in a <meta http-equiv> tag in index.html, but MDN says a meta CSP doesn’t support every feature: no frame-ancestors, and no report-only mode. That means no test run first, so we’d only do it after testing the same policy somewhere it can run report-only. Newer Lovable projects render on the server with TanStack Start. We haven’t checked whether headers set in that code survive Lovable’s hosting, so if you try it, check with curl.
Bolt
A Bolt app deployed to Netlify uses the Netlify block. For apps on Bolt’s own hosting, we couldn’t find a header setting in Bolt’s docs, and the one Bolt-hosted app we read sent only a minimal CSP. Read yours with curl. If something is missing, the Lovable options above (Cloudflare in front, or moving the frontend) work for Bolt too.
Roll it out without breaking your app
This is the order we’d do it in. Nothing is enforced until step 5.
- Read the headers your site sends today
- Add the five easy headers
- Add the CSP in report-only mode
- Use every screen and read the Console
- Switch the CSP to enforcing
- Check again after the deploy, and after the next ones
01
Read the headers your site sends today
Run the curl command from the top of this page on your live address and save the output. That’s your before picture, and it shows what your host already sends, so you don’t end up with two different HSTS headers.
02
Add the five easy headers
HSTS, nosniff, X-Frame-Options, Referrer-Policy and Permissions-Policy rarely break anything, so they can go straight in. The exceptions: an app that frames its own pages needs SAMEORIGIN, and an app that uses the camera, microphone or location needs that feature taken out of Permissions-Policy.
03
Add the CSP in report-only mode
Add the starter policy, plus any services from the table, as Content-Security-Policy-Report-Only. Every block on this page already does that. Deploy it.
04
Use every screen and read the Console
Open the live app with the DevTools Console open and use it the way a customer would: sign up, sign in, load data, upload a file, open the payment form, sign out. Each would-be block shows up as a message naming the directive and the address. For each one, decide whether it’s yours. If it is, add the host to the right directive. If you don’t recognise it, find out what loaded it before you allow it. Repeat until a full pass is quiet, apart from the upgrade-insecure-requests notice.
05
Switch the CSP to enforcing
Rename the header from Content-Security-Policy-Report-Only to Content-Security-Policy, keeping the value as it is, and deploy. Then do the step 4 walk-through once more, because now a missed host really is blocked. If something breaks, rename it back, which is a one-line rollback.
06
Check again after the deploy, and after the next ones
Run curl again and compare it with your before picture. Then remember the CSP is a list of everything your app talks to. When you, or your AI, add a new service, add its host to the policy in the same change, or that feature breaks the moment it ships.
Why we’d skip HSTS preload for now
preload asks browsers to build your domain into their HTTPS-only list, so even the very first visit is protected. MDN says it requires a max-age of at least a year and includeSubDomains. hstspreload.org, which runs the list, is blunt about it: “While HSTS is recommended, HSTS preloading is not recommended,” and getting off the list “tends to be slow and painful.” Its advice, if you do it, is to raise max-age in steps from five minutes to a week to a month and watch for broken pages. Once you’re preloaded, a subdomain that can’t do HTTPS simply stops working for everyone.
There’s a nice exception for a lot of vibe-coded apps. Some top-level domains are preloaded as a whole: get.dev says every .dev domain is on the list, and get.app says the same for .app. If your app lives on one of those, browsers already insist on HTTPS. Your own HSTS header matters less there, but it costs nothing to send.
What not to bother with
- X-XSS-Protection. MDN marks it deprecated and warns that it “can create XSS vulnerabilities” in otherwise safe sites. OWASP says not to set it, or to set it to
0. You will still see1; mode=blockin old tutorials, in Vercel’s own vercel.json headers example, and in what Cloudflare’s Add security headers transform adds. Don’t copy that part. - Expect-CT and Public-Key-Pins. OWASP marks both with a red cross and says not to use them. It explains that mainstream browsers now require Certificate Transparency on their own, and that Public-Key-Pins was removed from Chromium in 2018 and isn’t supported by any modern browser.
- Feature-Policy. It’s the old name for Permissions-Policy. Send the new one.
- X-Frame-Options: ALLOW-FROM. MDN marks it deprecated. Use frame-ancestors to name the sites allowed to frame you.
- Cross-origin isolation headers for now. The OWASP Secure Headers Project also recommends Cross-Origin-Embedder-Policy, Opener-Policy and Resource-Policy. They’re worth learning, but
require-corpin particular can break third-party images and scripts, and they protect against a different kind of attack than the ones above. Get the six working first. - Hiding the server name. Turning off
X-Powered-Byis good tidying, and OWASP lists it under headers to remove. It won’t stop anyone who opens your page source, so don’t spend long on it.
Where LockShip fits
We built LockShip because reading headers, and a dozen other things, is easy to forget once an app is live. You paste your app’s address and prove you own it, and the scan reads your app the way a stranger would. For headers, here is exactly what it looks at, all on your homepage response:
- No Content-Security-Policy is a medium finding. A policy that is only report-only is a low one that tells you to switch it on once the Console is quiet.
- No Strict-Transport-Security on HTTPS is medium.
- Neither X-Frame-Options nor frame-ancestors in an enforced CSP is medium, because the page can be framed.
- X-Content-Type-Options that isn’t nosniff is low, and a missing Referrer-Policy is low.
- A Server header with a version number, or an X-Powered-By header, is noted as information.
Next to headers it reads your CORS answer to an origin it makes up, the flags on the cookie your homepage sets, and your certificate. What it doesn’t do matters as much. It doesn’t judge how strict your CSP is, so a policy full of 'unsafe-inline' counts as present. It doesn’t read Permissions-Policy, doesn’t check your HSTS max-age, and doesn’t compare headers across your other pages. For those, the curl command and the Console are your tools.
The summary is free: a score and the single worst thing we found. The $39 Launch Safe certificate adds every finding with a fix prompt for your builder, a PDF and seven days of re-scans while you fix things, and a passing re-scan gives you a badge for your site. After that, Monitor re-scans about once a week for $9 a month, and the badge turns grey if a later scan finds a critical or high issue.
See what your live app sends
Paste your app’s address, prove you own it, and the scan reads your headers along with your keys, database rules, CORS and certificate. The summary is free.
Run a free scanBefore you rely on a result, read the disclaimer.
Questions people ask
Which security headers does a vibe-coded app actually need?
Six: Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options set to nosniff, X-Frame-Options or a CSP frame-ancestors directive, Referrer-Policy and Permissions-Policy. Five of them are one line each and almost never break anything. The CSP is the one that takes an afternoon, because it has to list every outside service your app calls from the browser.
Will a Content Security Policy break my Supabase or Stripe app?
An enforced policy that doesn’t list Supabase or Stripe will. In our test, a bare policy would have blocked Supabase requests, Supabase storage images, Stripe.js, Stripe’s card frame and Google Fonts. The starter policy on this page lists all of them and came back with zero violations in Chrome. That is why you start with Content-Security-Policy-Report-Only: the browser tells you what it would block, and blocks nothing.
Should I add preload to my HSTS header?
Not as a first step. hstspreload.org, the site that runs the preload list, says HSTS is recommended but preloading is not, and that removal tends to be slow and painful. Preload covers every subdomain you will ever create, so a forgotten HTTP-only subdomain stops working. If your domain ends in .app or .dev, the whole TLD is already on the preload list anyway.
Can I set security headers on Lovable hosting?
We couldn’t find a setting for custom response headers in Lovable’s hosting docs. Lovable does let you put your custom domain behind Cloudflare with its Uses Cloudflare or similar proxy option, and a Cloudflare Response Header Transform Rule can then add the headers. You can also sync the code to GitHub and host it on Netlify, Cloudflare Pages or Vercel, which Lovable’s own docs describe.
Do I still need X-XSS-Protection?
No. MDN marks it deprecated and warns it can create XSS vulnerabilities in otherwise safe sites, and OWASP’s cheat sheet says not to set it, or to set it to 0. Helmet sets it to 0 for that reason. Some host examples and Cloudflare’s Add security headers transform still add 1; mode=block. Your CSP is what does that job now.
Does the LockShip scan check all of these headers?
It checks five of the six on your homepage: whether a CSP is there (and whether it is only report-only), HSTS, nosniff, frame protection through X-Frame-Options or frame-ancestors, and Referrer-Policy. It doesn’t read Permissions-Policy, doesn’t check the HSTS max-age, doesn’t judge how strict your CSP is, and doesn’t compare headers across your other pages. It also reports Server and X-Powered-By headers that name your software, CORS, cookie flags and your certificate.
Keep reading
- Vibe coding security checklist: what to check before you launch an AI-built app
- Is my Lovable app secure? Seven checks you can do yourself
- Is my Bolt app secure? A plain check before you launch
- Is my v0 app secure? A plain check before you launch
- Is my Replit app secure? A plain check before you launch
- A Supabase RLS checklist for vibe-coded apps
- A Firebase Security Rules checklist for vibe-coded apps
Sources
We checked each of these on 19 October 2026. The test results are our own, from headless Chrome 155 against small test apps with fake Supabase and Firebase projects and Stripe’s public test key. No real payments or sessions were created. Docs change, so if anything here disagrees with them, trust the docs and tell us at hi@lockship.dev.
- Content Security Policy (CSP), MDN guide
- Content-Security-Policy header, MDN
- Content-Security-Policy-Report-Only header, MDN
- CSP frame-ancestors directive, MDN
- CSP upgrade-insecure-requests directive, MDN
- CSP report-to directive, MDN
- Strict-Transport-Security header, MDN
- X-Content-Type-Options header, MDN
- X-Frame-Options header, MDN
- Referrer-Policy header, MDN
- Permissions-Policy header, MDN
- X-XSS-Protection header, MDN
- OWASP Secure Headers Project
- HTTP Security Response Headers Cheat Sheet, OWASP
- headers, Next.js docs
- How to set a Content Security Policy, Next.js docs
- How to set a Content Security Policy, Next.js 15 docs
- poweredByHeader, Next.js docs
- Font module, Next.js docs
- Static configuration with vercel.json, Vercel docs
- Encryption and TLS, Vercel docs
- Custom headers, Netlify docs
- Headers, Cloudflare Pages docs
- Headers for static assets, Cloudflare Workers docs
- Response Header Transform Rules, Cloudflare docs
- Managed Transforms reference, Cloudflare docs
- Static Deployment configuration, Replit docs
- Helmet
- How Lovable hosts your app, Lovable docs
- Set up a custom domain, Lovable docs
- Deployment, hosting, and ownership options, Lovable docs
- Integration security guide, Stripe docs
- Best practices for using signInWithRedirect, Firebase docs
- HSTS Preload List Submission, hstspreload.org
- .dev, Google Registry
- .app, Google Registry