How to Configure Security Headers on Netlify
Protect your Netlify-deployed app with essential HTTP headers
TL;DR
TL;DR (15 minutes)
Add security headers on Netlify using a _headers file in your publish directory or [[headers]] sections in netlify.toml. For dynamic headers like CSP nonces, use Edge Functions. Configure X-Content-Type-Options, X-Frame-Options, HSTS, Referrer-Policy, and Content-Security-Policy.
Prerequisites
- A site deployed on Netlify (or ready to deploy)
- Access to your project's Git repository
- Knowledge of your publish directory (dist, build, public, etc.)
- Basic understanding of HTTP headers
Three Ways to Add Headers on Netlify
| Method | Best For | Dynamic Headers |
|---|---|---|
| _headers file | Simple, readable configuration | No |
| netlify.toml | Complex configs, environment-specific | No |
| Edge Functions | Dynamic CSP, nonces, logic | Yes |
Method 1: Using _headers File
The _headers file is the simplest option. One file, no extra configuration syntax.
Create _headers in your publish directory
Create a file named _headers (no extension) in your publish directory:
# Security headers for all pages
/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';
Add route-specific headers
# Default headers for all pages
/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains
# API routes - no caching, strict headers
/api/*
Cache-Control: no-store, max-age=0
X-Content-Type-Options: nosniff
Content-Type: application/json
# Allow embedding for widget
/widget
X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self' https://trusted-domain.com;
# Static assets - long cache
/assets/*
Cache-Control: public, max-age=31536000, immutable
# Admin area - strictest CSP
/admin/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; frame-ancestors 'none';
Ensure _headers is in the right location
The file must end up in your publish directory after build. Common locations:
| Framework | Place _headers in | Publish Directory |
|---|---|---|
| Create React App | public/ | build/ |
| Next.js (static) | public/ | out/ |
| Gatsby | static/ | public/ |
| Vue CLI | public/ | dist/ |
| Astro | public/ | dist/ |
| Hugo | static/ | public/ |
Method 2: Using netlify.toml
netlify.toml is more verbose, but it lets you scope headers by environment (production vs. deploy preview) and consolidate all your Netlify configuration in one place.
Create or update netlify.toml in project root
# Build configuration
[build]
publish = "dist"
command = "npm run build"
# Security headers for all routes
[[headers]]
for = "/*"
[headers.values]
X-Content-Type-Options = "nosniff"
X-Frame-Options = "DENY"
X-XSS-Protection = "1; mode=block"
Referrer-Policy = "strict-origin-when-cross-origin"
Permissions-Policy = "camera=(), microphone=(), geolocation=()"
Strict-Transport-Security = "max-age=31536000; includeSubDomains"
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https:; frame-ancestors 'none';"
Add route-specific headers
# Default security headers
[[headers]]
for = "/*"
[headers.values]
X-Content-Type-Options = "nosniff"
X-Frame-Options = "DENY"
Referrer-Policy = "strict-origin-when-cross-origin"
Strict-Transport-Security = "max-age=31536000; includeSubDomains"
# API routes
[[headers]]
for = "/api/*"
[headers.values]
Cache-Control = "no-store, max-age=0"
X-Content-Type-Options = "nosniff"
# Static assets with long cache
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
# Admin with strict CSP
[[headers]]
for = "/admin/*"
[headers.values]
Content-Security-Policy = "default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none';"
Use environment-specific headers (advanced)
# Production headers
[context.production]
[[context.production.headers]]
for = "/*"
[context.production.headers.values]
Strict-Transport-Security = "max-age=31536000; includeSubDomains; preload"
Content-Security-Policy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';"
# Deploy preview headers (less strict for testing)
[context.deploy-preview]
[[context.deploy-preview.headers]]
for = "/*"
[context.deploy-preview.headers.values]
Content-Security-Policy = "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data: https:;"
Method 3: Using Edge Functions (Dynamic Headers)
The _headers file and netlify.toml are static. If you need a CSP nonce per request (required for strict CSP without unsafe-inline), you'll need an Edge Function instead.
Create Edge Function
Create netlify/edge-functions/security-headers.ts:
import type { Context } from "@netlify/edge-functions";
export default async function handler(request: Request, context: Context) {
// Get the response from the origin
const response = await context.next();
// Generate a unique nonce for CSP
const nonce = crypto.randomUUID().replace(/-/g, '');
// Build CSP with nonce
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
"img-src 'self' blob: data: https:",
"font-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"upgrade-insecure-requests"
].join('; ');
// Clone headers and add security headers
const headers = new Headers(response.headers);
headers.set('Content-Security-Policy', csp);
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('X-Frame-Options', 'DENY');
headers.set('X-XSS-Protection', '1; mode=block');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
headers.set('X-Nonce', nonce);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers
});
}
export const config = {
path: "/*",
excludedPath: ["/api/*", "/_next/*", "/assets/*"]
};
Configure Edge Function in netlify.toml
[[edge_functions]]
function = "security-headers"
path = "/*"
Access nonce in your HTML (optional)
For server-rendered pages, you can read the nonce from the X-Nonce header:
// In your server-side code or build process
export async function getServerSideProps({ req }) {
const nonce = req.headers['x-nonce'] || '';
return {
props: { nonce }
};
}
// In your component
function MyPage({ nonce }) {
return (
<html>
<head>
<script nonce={nonce}>
console.log('Inline script with nonce');
</script>
</head>
<body>...</body>
</html>
);
}
Before you go live: confirm the _headers file landed in your publish directory, not the project root. Run netlify build --dry to catch netlify.toml syntax errors. Test your CSP with Content-Security-Policy-Report-Only before switching to enforcement mode. Third-party scripts (analytics, chat widgets) need explicit CSP allowances. Start HSTS with max-age=300 for a week before committing to 31536000.
How to Verify It Worked
Netlify Deploy Log
In the Netlify dashboard, open the deployment and expand the deploy log. Look for "Processing headers" messages and any warnings about _headers or netlify.toml.
Netlify CLI
# Install Netlify CLI
npm install -g netlify-cli
# Test your configuration locally
netlify dev
# Check what headers would be applied
netlify build --dry
Browser DevTools
Open your deployed site, press F12, go to the Network tab, reload the page, click the document request, and check Response Headers.
Command Line
# Check headers with curl
curl -I https://your-site.netlify.app
# Expected output:
# HTTP/2 200
# x-content-type-options: nosniff
# x-frame-options: DENY
# strict-transport-security: max-age=31536000; includeSubDomains
# referrer-policy: strict-origin-when-cross-origin
# content-security-policy: default-src 'self'; ...
Common Errors and Troubleshooting
_headers file not working
The _headers file must be in your publish directory, not the project root. It must be named exactly _headers with no extension. If your build process doesn't copy it there, it won't ship. Check for syntax errors: path on its own line, headers indented below it.
netlify.toml headers not applying
Run netlify build --dry to catch TOML syntax errors before they cost you a deploy. If _headers and netlify.toml both define the same route, they get merged; check for conflicts. Also confirm your for path pattern actually matches the route you're testing.
CSP blocking content
Open the browser console: CSP violations show up with detailed messages about exactly which directive blocked which resource. Use Content-Security-Policy-Report-Only to collect violations without blocking, then fix the allowlist before switching to enforcement.
Edge Function not running
Edge Functions must live in netlify/edge-functions/. The config export with a path value is required. If it's missing, the function registers but never runs. Check Edge Function logs in the Netlify dashboard under Functions.
Each Netlify PR gets its own deploy preview URL. Test your security headers there first. Mistakes in CSP can break your analytics, fonts, or third-party scripts in ways that aren't obvious until real users hit the page.
Frequently Asked Questions
Where should I put the _headers file on Netlify?
The _headers file must be in your publish directory (the folder Netlify serves). For most projects, this is 'public', 'dist', or 'build'. For static site generators, place it in your static assets folder so it gets copied to the build output.
Should I use _headers or netlify.toml for security headers?
_headers is simpler and easier to read at a glance. netlify.toml is worth using when you need environment-specific headers (e.g., stricter CSP in production, looser in deploy previews) or you're already using it for build config. You can use both at once; they get merged, with netlify.toml winning on conflicts.
Does Netlify add any security headers by default?
Netlify adds some basic headers like X-NF-Request-ID for tracking, but it doesn't add security headers by default. You have to configure them yourself via _headers, netlify.toml, or Edge Functions.
Why aren't my _headers working on Netlify?
Most likely causes: the file isn't in the publish directory, the filename has a typo or extension, there's a syntax error in the file, or netlify.toml has conflicting headers that override yours. The deploy log usually tells you which one.
Can I have different headers for different routes on Netlify?
Yes. In _headers, add separate sections for each path. In netlify.toml, add multiple [[headers]] blocks with different 'for' values. You can use wildcards like /api/* or /admin/*.
Scan Your Netlify Site
Check if your Netlify-hosted site has all the security headers configured correctly.