How to add security headers on Netlify with a _headers file
A header checker says your Netlify site is missing CSP, X-Frame-Options and more. Where to set them, a working _headers file, and the traps.
· 7 min read
Your site is on Netlify, it loads fine, the padlock is there, and a header checker still hands you a list of failures: no Content-Security-Policy, no X-Frame-Options, no X-Content-Type-Options, no Referrer-Policy. You never configured a web server, so it's not obvious where these headers are supposed to come from.
On Netlify they come from one of two files in your project: a plain-text _headers file or a [[headers]] block in netlify.toml. Below: why the headers are missing, how to see what your site sends today, a starting configuration, and two traps that make headers silently not apply.
Why a Netlify site ships without these headers
Netlify serves your files over HTTPS, but which response headers to add is a decision it leaves to the project. The tools that build most small sites today (Lovable, Bolt, Cursor, a Vite or React template) generate pages and components. A prompt like "build me a landing page with a signup form" never mentions response headers, so the output has none.
What the missing headers do:
X-Content-Type-Options: nosnifftells the browser not to guess a file's type. Without it, a file that should be treated as text or an image can, in some cases, be run as a script.X-Frame-Options: DENY(orframe-ancestors 'none'inside a CSP) stops other sites from loading your page in a hidden iframe and tricking visitors into clicking on it. That attack is called clickjacking.Referrer-Policy: strict-origin-when-cross-originkeeps full URLs, including IDs or tokens in the path and query string, from being sent to every third-party site your page links to or loads from.Content-Security-Policylists where the page may load scripts, styles, images and data from. If someone manages to inject a script, the browser refuses to run it from an unlisted place. It's also the header that can break your site, so it gets its own section below.Strict-Transport-Security(HSTS) tells browsers to always use HTTPS for your domain. Many Netlify sites already return it, but Netlify's docs don't promise a default value, so check your own site instead of assuming.
None of these makes a site secure by itself. They're cheap defaults that close a few common doors, and that's why every checker asks for them.
Check it yourself
1. See what your live site sends
curl -sI https://yourdomain.com | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer-policy'
Run it against your custom domain and against a deep page (https://yourdomain.com/pricing), not just the home page. If you share the *.netlify.app address anywhere, check that too.
No terminal? Open the site in Chrome, open DevTools (F12), go to the Network tab, reload, click the first request and read Response Headers.
2. Find where headers are configured now
Look in your repository for:
- a file named
_headers, usually inpublic/or in the site root; [[headers]]blocks innetlify.toml;- for Next.js or another framework with server rendering: a
headers()function innext.config.*, or headers set in middleware or functions.
If you find more than one place, write down which header comes from where before you change anything.
3. Make sure the file reaches the deploy
Netlify reads _headers from the publish directory, the folder that actually gets deployed, as the custom headers docs say. For a Vite or Create React App project that folder is dist or build, not the repository root. A _headers file sitting next to package.json is ignored.
The usual fix: put the file in public/_headers. Vite and CRA copy everything in public/ into the build output, so it lands in dist/_headers. After a build, run ls dist and confirm it's there. Check the publish directory in Site configuration → Build & deploy if you're not sure which folder it is.
Fix it
Option A: a _headers file
The format is a path on its own line, then indented Header: value lines. /* matches every path.
/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy-Report-Only: default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'
Lines starting with # are comments. If your site is meant to be embedded somewhere (a booking widget on a partner's page), use SAMEORIGIN or list that site in frame-ancestors instead of DENY.
If curl showed no strict-transport-security line, add Strict-Transport-Security: max-age=31536000 too. Netlify's HTTPS documentation also describes a longer value with includeSubDomains; preload, and warns that preloading is hard to undo. Don't add preload unless every subdomain you own already works over HTTPS.
Option B: netlify.toml
The same rules in netlify.toml, which lives in the repository root:
[[headers]]
for = "/*"
[headers.values]
X-Content-Type-Options = "nosniff"
X-Frame-Options = "DENY"
Referrer-Policy = "strict-origin-when-cross-origin"
Content-Security-Policy-Report-Only = "default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'"
Either option works. Pick one and keep every security header there; the docs don't spell out how a conflict between the two files is resolved, so don't make Netlify decide.
Trap: pages served by functions don't get these headers
Netlify applies custom headers only to files it serves from its own storage. The docs are explicit that proxied responses, and URLs handled by a function or edge function, such as a server-rendered page, don't get them; the function has to return its own headers.
For a static Vite or React build that's not a problem: every page is a file. For Next.js, Astro in server mode, Remix or SvelteKit on Netlify, it means your _headers file can cover the CSS and images while the HTML pages arrive without a single security header. Set headers in the framework instead (headers() in next.config, middleware, or the framework's own hook), then check an HTML page with curl, not just the home page.
The Content-Security-Policy, carefully
Both examples ship the CSP as Content-Security-Policy-Report-Only. The browser logs violations in the console but blocks nothing, so the site keeps working while you learn what it really loads: your Supabase or API domain, Google Fonts, analytics, a Stripe iframe, Netlify Forms. Once a full click-through of the site leaves the console clean, rename the header to Content-Security-Policy.
We walked through that process step by step in Content Security Policy for a React + Vite app. If you also run a project on Vercel, the same headers go into vercel.json there; see How to add security headers on Vercel.
A prompt for your AI assistant
Paste this into Cursor, Claude Code, Lovable or Bolt:
Add security response headers to this site, which is deployed on Netlify.
1. Find every place headers are set now: a _headers file (root or public/),
[[headers]] in netlify.toml, and headers set in framework config,
middleware or functions. Tell me what each one sets.
2. Find the publish directory (netlify.toml "publish" or the build output
folder). If this is a static build, create public/_headers so it is
copied into the publish directory. If pages are server-rendered by a
function, set the headers in the framework instead and tell me.
3. Keep all security headers in ONE place and remove duplicates.
4. Add for all paths (/*):
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY (ask me first if the site must be embedded anywhere)
- Referrer-Policy: strict-origin-when-cross-origin
5. Add Content-Security-Policy-Report-Only built from the domains this
site actually loads (API, Supabase, fonts, analytics, payment iframes),
with frame-ancestors 'none', object-src 'none' and base-uri 'self'.
Do NOT enforce it yet.
6. Do not add HSTS preload.
7. Show me the curl command to check the headers on an HTML page 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 page. All the headers should be there. Open the site with DevTools and click through the main flows; any report-only CSP warnings in the console are your to-do list before you switch to enforcing.
Headers tend to vanish without anyone noticing. A redesign moves the build output from build to dist, a framework migration turns static pages into server-rendered ones, or an assistant "tidies up" netlify.toml and drops the block. The site keeps working, so nobody looks.
ScanYourSite checks this from the outside. The free check reads your live site 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 to paste back into your assistant. The free plan checks one site once a week and emails a weekly summary; Pro checks daily and after every production deploy, and alerts you right away when a header disappears.
Try it on your domain: https://scanyoursite.app/?check=yourdomain.com.