Skip to content
StackSiren

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/_headers so vite build copies it into dist/. 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 an include entry. Vite's public/ 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:

  1. Confirm the deploy you are hitting is the new one (Netlify → Deploys).
  2. Confirm _headers landed in the publish directory (download the deploy or check the build log for the copied file).
  3. Remember: function / SSR routes will not pick up _headers — set those in code.
  4. Test the custom domain users visit, not only the *.netlify.app URL.

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

  1. Putting _headers only at the repo root. Netlify looks in the publish directory. For Vite, that usually means public/_headers → dist/_headers. Download a deploy artefact or check the publish folder after a local build if you are unsure.
  2. Setting headers in a <meta> tag. Only CSP partly works that way, and frame-ancestors is ignored in a meta tag. Send real HTTP headers.
  3. 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.
  4. Testing only *.netlify.app. Custom domains can differ (CDN, HTTPS, redirects). Always confirm the hostname users visit.
  5. Expecting _headers to 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.

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.

Check your app now

Free passive scan, results in under a minute.

Run a free scan

Fix guides for this topic

Platform checklists: Bolt · Lovable

Related posts

Sources