How to add security headers on Vercel with vercel.json
A header checker flags your Vercel site for missing headers. What Vercel sends by default, how to add the rest in vercel.json or next.config, and how to test it.
· 6 min read
You run your Vercel site through a header checker and get a list: no Content-Security-Policy, no X-Frame-Options, no X-Content-Type-Options, no Referrer-Policy. The site works, the padlock is there, and you're not sure whether this list matters or how to fix it on a platform where you never touch a web server.
On Vercel the fix lives in your repository: a headers block in vercel.json, or a headers() function in next.config for a Next.js app. Below: what Vercel already sends, which headers to add and why, a starting configuration, and how to check the result on the live site.
What Vercel sends by default, and what it doesn't
Vercel serves everything over HTTPS and adds one security header on its own: Strict-Transport-Security (HSTS), which tells browsers to use HTTPS for your domain from now on. According to Vercel's encryption docs, the default is max-age=63072000 (two years), and you can override it with your own header.
Everything else is up to your project. Frameworks don't fill the gap either: a v0 or Next.js app ships without a Content-Security-Policy unless someone writes one. The headers worth adding:
X-Content-Type-Options: nosniffstops the browser from guessing a file's type. Without it, a file uploaded as an image can, in some cases, be treated as a script.X-Frame-Options: DENY(orframe-ancestors 'none'in a CSP) stops other sites from loading your page in an invisible iframe and tricking visitors into clicking buttons on it. This attack is called clickjacking.Referrer-Policy: strict-origin-when-cross-originkeeps full URLs, including tokens or IDs in the path and query string, from leaking to every third-party site your page links to or loads from.Content-Security-Policylists where the page may load scripts, styles and data from. It limits the damage if someone manages to inject a script. It is also the one that can break your site, so it gets its own step below.
None of these makes a site safe on its own. They are cheap defaults that close a few common doors, which is why every checker asks for them.
Why AI-built Vercel apps ship without them
v0, Cursor and Lovable-style tools generate pages and components. Response headers are deployment configuration, and nothing in a typical prompt ("build me a booking app") asks for them. The project gets a next.config.js with image settings and maybe a redirect, and that's it.
There is a second trap: two places to set headers. A Next.js project can use headers() in next.config, and any project on Vercel can use vercel.json. Assistants sometimes add both, with different values, and then you're debugging which one wins. Pick one place and keep all security headers there.
Check it yourself
1. Look at the headers your live site sends
curl -sI https://yourdomain.com | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer-policy'
On a fresh Vercel deployment you'll usually see only strict-transport-security. Check a deep page too, not just the home page (https://yourdomain.com/dashboard), and the *.vercel.app address if you share it. A header rule with the wrong source pattern covers some paths and silently skips others.
No terminal? Open the site in Chrome, open DevTools, go to Network, reload, click the first request (your page) and read Response Headers.
2. Find out where headers are configured now
In your repository, look for:
- a
headerskey invercel.json; - an
async headers()function innext.config.js,next.config.mjsornext.config.ts; response.headers.set(...)inmiddleware.ts(namedproxy.tsin newer Next.js versions).
If you find more than one, write down which header comes from where before changing anything.
Fix it
Option A: vercel.json (any framework)
This works for Vite, Astro, plain static sites and Next.js alike. The headers array takes a source path pattern and a list of key/value pairs, as described in the vercel.json reference:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{
"key": "Content-Security-Policy-Report-Only",
"value": "default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'"
}
]
}
]
}
"/(.*)" matches every path. If your app is meant to be embedded somewhere (a widget on a partner's site), use SAMEORIGIN or list that site in frame-ancestors instead of DENY.
Option B: next.config (Next.js and v0 apps)
If the project is Next.js, you can keep everything in next.config with the headers() function:
// next.config.mjs
const securityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
];
export default {
async headers() {
return [{ source: "/:path*", headers: securityHeaders }];
},
};
Either option is fine. Just don't use both for the same header.
The Content-Security-Policy, carefully
The example above ships the CSP as Content-Security-Policy-Report-Only. The browser reports violations in the console but blocks nothing, so the site keeps working while you find out what it really loads: your Supabase or API domain, fonts, analytics, payment iframes, Vercel Analytics. Once the console is clean through a full click-through of the app, rename the header to Content-Security-Policy.
A Next.js app usually needs more than a static Vite build, because the framework adds inline scripts to the page. Next.js documents a nonce-based setup for that case in its Content Security Policy guide. We walked through the report-only workflow step by step in Content Security Policy for a React + Vite app; the same method applies here.
A prompt for your AI assistant
Paste this into v0, Cursor or Claude Code:
Add security response headers to this project deployed on Vercel.
1. Find every place headers are set now: vercel.json "headers",
headers() in next.config.*, and middleware.ts or proxy.ts. Tell me what each sets.
2. Keep all security headers in ONE place (next.config headers() for Next.js,
vercel.json otherwise) and remove duplicates elsewhere.
3. Add for all paths:
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY (ask me first if the app must be embedded anywhere)
- Referrer-Policy: strict-origin-when-cross-origin
4. Add a Content-Security-Policy-Report-Only header built from the domains
this app actually loads (list them from the code: API, Supabase, fonts,
analytics, payment iframes). Include frame-ancestors 'none',
object-src 'none' and base-uri 'self'. Do NOT enforce it yet.
5. Do not change Strict-Transport-Security; Vercel already sends it.
6. Show me the curl command to verify the headers after deploy.
Verify after deploy, and keep it that way
Deploy, then run the curl command from step 1 again, on the home page and on a deep path. All four headers should be there. Open the site with DevTools and click through the main flows; CSP report-only warnings in the console are your to-do list before you switch to enforcing.
Headers tend to disappear quietly. Someone asks the assistant to "clean up the config", a migration from vercel.json to next.config drops half the list, or a new route sits outside the source pattern. The site still works, so nobody notices. If you also keep secrets in environment variables, the same kind of quiet change can push one into the browser bundle; see NEXT_PUBLIC_ env variable exposed a secret?.
That's what ScanYourSite is for. The free check reads your live site from the outside and reports missing HSTS (or a short max-age), a missing or weak Content-Security-Policy, missing clickjacking protection, missing X-Content-Type-Options and Referrer-Policy, server headers that reveal software versions, and session cookies without Secure, HttpOnly or SameSite. Each finding comes with a prompt you can paste back into your assistant. The free plan checks one site once a week and sends a weekly summary; Pro checks daily and after every production deploy, and alerts you right away when a header goes missing.
Try it on your domain: https://scanyoursite.app/?check=yourdomain.com.