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: nosniff tells 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 (or frame-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-origin keeps 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-Policy lists 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 in public/ or in the site root;
  • [[headers]] blocks in netlify.toml;
  • for Next.js or another framework with server rendering: a headers() function in next.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.

Check your own site in a minute

Free, no sign-up. You get a prioritised list of what’s wrong and a prompt you can paste back into Lovable, Bolt, Cursor or Claude Code.

Free, no sign-up. The basic check makes only the requests an ordinary browser would.

Security headers