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 simplest and most readable approach.
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
More verbose but offers additional features.
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)
Use Edge Functions when you need dynamic headers like CSP nonces.
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>
);
}
Security Checklist for Netlify Deployments
- Verify _headers file is in the publish directory (not project root)
- Check netlify.toml syntax with the Netlify CLI:
netlify build --dry - Test CSP with Content-Security-Policy-Report-Only first
- Ensure third-party scripts (analytics, chat widgets) are allowed in CSP
- Start HSTS with short max-age (300) before increasing to 31536000
- Test on deploy previews before production deployment
How to Verify It Worked
Method 1: Netlify Deploy Log
- Go to your site in the Netlify dashboard
- Click on a deployment
- Expand the deploy log
- Look for "Processing headers" messages
- Check for any warnings about _headers or netlify.toml
Method 2: 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
Method 3: Browser DevTools
- Visit your deployed Netlify site
- Open DevTools (F12)
- Go to Network tab
- Reload the page
- Click on the document request
- Check Response Headers section
Method 4: 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
- Wrong location: Must be in publish directory, not project root
- Wrong filename: Must be exactly
_headerswith no extension - Build process issue: File might not be copied to build output
- Syntax error: Check for proper formatting (path on its own line, headers indented)
netlify.toml headers not applying
- TOML syntax: Use a TOML validator or
netlify build --dry - Conflicting with _headers: Both files are merged; check for conflicts
- Wrong path pattern: Ensure
formatches your routes
CSP blocking content
- Check console: Browser console shows CSP violation details
- Use report-only: Test with
Content-Security-Policy-Report-Onlyfirst - Add missing sources: Include third-party domains in appropriate directives
Edge Function not running
- File location: Must be in
netlify/edge-functions/ - Export config: Ensure path configuration is exported
- Check logs: Review Edge Function logs in Netlify dashboard
Pro Tip
Use Netlify's deploy previews to test security headers on feature branches before merging to production. Each PR gets a unique URL where you can verify headers work correctly without affecting your live site.
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. netlify.toml offers more features like environment-specific headers. Both work equally well. You can even use both - they get merged, with netlify.toml taking precedence for conflicts.
Does Netlify add any security headers by default?
Netlify adds some basic headers like X-NF-Request-ID for tracking but does not add security headers by default. You must configure all security headers yourself via _headers, netlify.toml, or Edge Functions.
Why aren't my _headers working on Netlify?
Common issues: File not in publish directory, wrong filename, syntax errors, or conflicting headers in netlify.toml. Check your deploy log for header-related warnings.
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.
Start Free Scan