How do I add security headers on Netlify? (_headers / netlify.toml)
By StackSiren · Published
Add a _headers file to your publish directory or a [[headers]] block in netlify.toml, deploy, then confirm with curl -I against your production domain. Netlify does not invent these for you, and a file in the wrong folder fails silently.
Bolt exports and many Vite apps land on Netlify. The same headers matter as on Vercel — CSP, HSTS, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy — only the syntax changes. For the Vercel / Next.js version, see how to add security headers on Vercel. The security headers fix guide explains what each header does.
Which headers to set
| Header | Starter value |
|---|---|
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=() |
Content-Security-Policy |
Start in report-only mode (below) |
Only add preload to HSTS if every subdomain serves HTTPS and you intend to submit the domain to the HSTS preload list. Removing a domain from that list is slow.
Option 1: _headers in the publish directory
Netlify reads a plain-text _headers file from the publish directory (the folder that actually gets deployed), not necessarily the repo root. For a Vite app that publishes dist, the file must end up as dist/_headers. For Next.js on Netlify, put it in public/ so the build copies it into the publish output — a file at the repo root alone is ignored.
/*
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=()
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.supabase.co; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
Notes from Netlify's docs:
- Paths can use wildcards (
/*matches every path). Indent each header under its path. - Lines starting with
#are comments. Header names are case-insensitive. - For Vite, keep a copy in
public/_headerssovite buildcopies it intodist/. Confirm your publish directory in Site configuration → Build & deploy matches where the file lands. - Some static generators ignore files that start with
_; Jekyll, for example, needs anincludeentry. Vite'spublic/directory does not have that problem.
Option 2: [[headers]] in netlify.toml
netlify.toml lives at the repo root (or the base directory Netlify builds from). This form is better when you already manage build settings, redirects or environment config in one file:
[[headers]]
for = "/*"
[headers.values]
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=()"
Content-Security-Policy-Report-Only = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.supabase.co; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
Pick one place for each header. Netlify processes _headers rules before netlify.toml rules; overlapping duplicates make debugging harder. Prefer _headers for static Vite sites that already use public/, and netlify.toml when you want everything in one config file.
Limitations worth knowing
Netlify documents that custom headers apply only to files it serves from its own store. They are not applied to:
- Responses from Netlify Functions or Edge Functions
- Server-side rendered (SSR) pages handled by a function
- Proxied / redirected content to another origin
In those cases, set the headers in the function or upstream response instead. Also, some header names are reserved (Content-Length, Location, Set-Cookie and others) and Netlify ignores or overrides them.
Branch- or context-specific headers are not a built-in switch on a global _headers / [[headers]] block. Netlify's workaround is to copy a per-context header file into the publish directory at the end of that context's build command.
Rolling out Content Security Policy without breaking the app
CSP is the header most likely to break scripts, fonts or API calls, so ship it in two steps.
Step 1: report-only. Use Content-Security-Policy-Report-Only as in the examples above. The browser enforces nothing but logs violations in DevTools. Click through sign-in, payments and any embeds, and add each legitimate source:
- Supabase:
connect-src https://YOUR-PROJECT.supabase.co wss://YOUR-PROJECT.supabase.co - Stripe:
script-src https://js.stripe.com; frame-src https://js.stripe.com https://checkout.stripe.com - Google Fonts:
style-src https://fonts.googleapis.com; font-src https://fonts.gstatic.com
Step 2: enforce. When the console is quiet, rename the header to Content-Security-Policy.
'unsafe-inline' in script-src weakens CSP. Many small Vite apps start with it and tighten later. Keep frame-ancestors 'none' for clickjacking protection alongside X-Frame-Options.
Verify with curl after deploy
Only check domains you own. Push, wait for the deploy to go live, then:
curl -sI https://yourapp.com/ | grep -iE 'strict-transport|x-content-type|x-frame|referrer-policy|permissions-policy|content-security'
You should see one line per header. Also check a deep link and a static asset, because path rules can differ:
curl -sI https://yourapp.com/dashboard | grep -i x-frame-options
curl -sI https://yourapp.com/favicon.ico | grep -i x-content-type-options
If nothing shows:
- Confirm the deploy you are hitting is the new one (Netlify → Deploys).
- Confirm
_headerslanded in the publish directory (download the deploy or check the build log for the copied file). - Remember: function / SSR routes will not pick up
_headers— set those in code. - Test the custom domain users visit, not only the
*.netlify.appURL.
Our security headers checker runs the same checks and explains each result.
SPA rewrites and headers together
Vite SPAs on Netlify usually need a rewrite so /dashboard serves index.html. In netlify.toml:
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[[headers]]
for = "/*"
[headers.values]
X-Content-Type-Options = "nosniff"
X-Frame-Options = "DENY"
Referrer-Policy = "strict-origin-when-cross-origin"
Status 200 (not 301) is the SPA fallback. Static files that exist on disk are still served first, so /assets/*.js keeps working.
CORS is a different header set
Access-Control-Allow-Origin relaxes the browser's same-origin rules for APIs. Do not add Access-Control-Allow-Origin: * site-wide in _headers "to be safe". It belongs only on API routes that other origins must call. See the CORS misconfiguration guide.
Common mistakes
- Putting
_headersonly at the repo root. Netlify looks in the publish directory. For Vite, that usually meanspublic/_headers→dist/_headers. Download a deploy artefact or check the publish folder after a local build if you are unsure. - Setting headers in a
<meta>tag. Only CSP partly works that way, andframe-ancestorsis ignored in a meta tag. Send real HTTP headers. - Copying a strict CSP from another site. It will block your own Supabase, Stripe or analytics calls. Build yours from report-only logs on your app.
- Testing only
*.netlify.app. Custom domains can differ (CDN, HTTPS, redirects). Always confirm the hostname users visit. - Expecting
_headersto cover a Function. They will not. Return headers from the function handler, or put static assets on a path that Netlify serves from the store.
Preview and branch deploys
Headers declared globally in _headers or netlify.toml apply to every deploy context. That is usually what you want for security headers: previews should look like production so you can catch CSP breakage early. If you truly need different headers per branch, Netlify documents a workaround: keep per-context header files in a custom folder and cp the right one to the publish directory's _headers at the end of that context's build command. Do not leave overlapping global rules in place when you do that, or debugging becomes guesswork.
Related reading
- How to add security headers on Vercel
- Are Vite env variables secret?
- Setting up Supabase RLS in a Lovable app
FAQ
Does Netlify add security headers automatically?
No. You configure them with _headers or [[headers]] in netlify.toml. Confirm on your own domain with curl -I.
Where should _headers live for a Vite app?
In the publish directory after build — typically via public/_headers so Vite copies it into dist/. A file only at the repo root is ignored unless that root is also the publish directory.
Will these headers slow the site down? No meaningful effect. They add a few hundred bytes per response.
My app embeds itself in an iframe. Is X-Frame-Options: DENY a problem?
Use SAMEORIGIN and frame-ancestors 'self', or list the specific origins that may frame you in frame-ancestors.
Do headers apply to Netlify Functions?
No. Custom headers from _headers / netlify.toml apply to files Netlify serves from its store. Functions and SSR responses must set their own headers in code.
Do headers fix leaked keys or weak RLS? No. Headers protect visitors' browsers. Secrets and database access are separate: see the exposed API keys and related guides, or run a free StackSiren scan on a site you own.
Is X-XSS-Protection still needed?
No. Modern browsers have removed the XSS auditor it controlled, and MDN recommends a CSP instead. Leave it out or set it to 0.
How do I keep headers from regressing?
Add the curl -I check to CI after each deploy, or use scheduled scans. StackSiren's first scan is free; paid plans start at Solo ($29/mo) with scheduled rescans.
Fix guides for this topic
Platform checklists: Bolt · Lovable