Hosting platform

Content Security Policy on Netlify

A Netlify site's policy is two lines in a _headers file. It is easy to write, easy to put where Netlify never reads it, and it covers less of a modern site than it seems to.

A complete report-only config

One rule for every path, in a _headers file in your publish directory, the built output (dist/, public/, _site/), not the repository root. Some static site generators drop files whose names start with an underscore unless told to copy them. If yours does, [[headers]] in netlify.toml takes the same rule.

_headers
# In the publish directory (dist/, public/, _site/ ...), not the repository
# root: Netlify only reads the file from what it deploys.
/*
  Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; report-uri https://your-ingest-code.ingest.us.headerhawk.com/r; report-to csp-endpoint
  Reporting-Endpoints: csp-endpoint="https://your-ingest-code.ingest.us.headerhawk.com/r"

How this was checked: served from a static publish directory with netlify dev (netlify-cli 27.5.2): both headers arrive, and the browser applies the policy and posts a report. netlify dev emulates Netlify's header handling locally; the file was not deployed to Netlify's CDN.

Gotcha

_headers does not reach functions, edge functions or proxied content

Netlify applies custom headers to what it serves from your publish directory. Its documentation says what that leaves out: content proxied from another service through a redirect rule, and responses from Netlify Functions and Edge Functions, which have to set their own headers.

On a static site that leaves out nothing. On a site with server-rendered routes, an API that returns HTML, or a /blog/* proxied to another host, the _headers file covers the static files and misses the documents, which are the only responses a policy governs. The static homepage has the header, so checking it proves nothing.

# The homepage is static, so this passes either way:
curl -sI https://your-site.netlify.app/ | grep -i content-security

# A server-rendered or proxied route is the one to check:
curl -sI https://your-site.netlify.app/account/ | grep -i content-security

For those responses, set the header in the function that renders them, or use Netlify's Content Security Policy extension, which runs as an edge function and adds a per-request nonce.

Gotcha

Two rules setting the same header: nobody says what you get

A site-wide policy for /* and a tighter one for /admin/* is the natural file to write. Both rules match /admin/, and Netlify's headers documentation does not say what happens then.

/*
  Content-Security-Policy-Report-Only: default-src 'self'; ...

/admin/*
  Content-Security-Policy-Report-Only: default-src 'none'; ...

The two places you can look disagree. Under netlify dev, the later rule in the file wins outright: swap the order and /admin/ gets the other policy. Netlify support has described production joining the values with commas instead. Inside a CSP header, a comma separates two complete policies, and the browser applies both.

Content-Security-Policy-Report-Only: default-src 'self'; ..., default-src 'none'; ...
#                                                            ^ two policies, both applied

One gives /admin/ whichever policy the file order favours, and the other gives it both. Give each header exactly one matching rule.

Now see what it's blocking

A report-only policy blocks nothing. When a browser loads a page and finds something the policy would have refused, it posts a report to the endpoint in report-uri and report-to. Until something listens there, those reports go nowhere.

HeaderHawk is one place to send them. It accepts both report formats on the same URL, groups them by directive and blocked host so a thousand reports about one script read as one issue, and keeps extension and bot reports visible but out of alerts. Replace your-ingest-code in the config above with the code from your site's setup panel, or paste the URL the panel shows.

The Netlify integration guide covers that step: where the endpoint goes on Netlify, and how to confirm the headers are being served.

A policy is the first of several controls browsers enforce on the code your pages load, including Integrity-Policy, Permissions-Policy, COOP and COEP, and they all report to the same endpoint. What client-side security is covers the rest of that layer.

Start reading the reports

Free for 1 site. Leave the policy in report-only until the reports go quiet, then enforce it.