Learn
How to configure security headers in Cloudflare, nginx and Vercel without breaking client sites
Adding security headers is easy; adding them across a portfolio of client sites without breaking a checkout, an embedded booking widget or a payment redirect is the part that takes care. This guide covers where to set headers on the platforms agencies use most (Cloudflare, nginx, Apache, Vercel, Netlify), how to avoid the duplicates and silent overrides each one is prone to, and a rollout order that does not break client sites.
For what each header does and a safe value for each, see the HTTP security headers cheat sheet. This guide is about where and how to deploy them.
Pick one layer per site
Every request passes through several layers that can each add headers: the application (WordPress, Next.js), the web server or platform, and the CDN. Headers set in two layers either arrive twice, with browsers applying both (for CSP, the stricter combination of two policies you did not intend) or with one layer silently overriding the other. Decide, per site, which layer owns security headers, write it in the site's handover notes, and remove them everywhere else.
For agencies, the CDN or platform layer is usually right: one configuration per site, under version control or in the CDN dashboard, independent of the application and its plugins.
Cloudflare
- HSTS: SSL/TLS → Edge Certificates → HTTP Strict Transport Security. Start with a short max-age, no
includeSubDomains, no preload. - Other headers: Rules → Transform Rules → Modify Response Header, with a rule matching the hostname and one "set static" operation per header.
- Minimum TLS version: SSL/TLS → Edge Certificates → Minimum TLS Version, set to 1.2.
- Watch for: the origin also sending the same headers. A Transform Rule's "set" replaces the origin's value; "add" appends a second one. Use "set".
- Cloudflare Pages also reads a
_headersfile in the build output, in the same format as Netlify's below.
nginx
# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
Then include snippets/security-headers.conf; in the server block and in every location block that has its own add_header, because a single add_header in a location discards everything inherited from the server block. This is the most common reason headers "work on the home page but not on images". Set ssl_protocols TLSv1.2 TLSv1.3; and server_tokens off; at the same time.
Apache
Use Header always set in the virtual host, or in .htaccess on shared hosting, inside <IfModule mod_headers.c>. always also covers error pages. Apache keeps headers set with and without always in separate tables, so if PHP or a WordPress plugin also sends a header, Header always set can leave two copies. Stop the application sending it, or clear both tables first with Header unset and Header always unset. Copy-paste block in the cheat sheet.
Vercel
In vercel.json at the project root:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Strict-Transport-Security", "value": "max-age=31536000" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(), payment=()" },
{ "key": "Content-Security-Policy-Report-Only", "value": "default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" }
]
}
]
}
Vercel already sends HSTS on its own domains and on custom domains it serves; check what arrives before adding your own. For Next.js, the same array can be returned from async headers() in next.config.js instead; use one or the other, not both. A strict CSP on Next.js needs nonces, which the framework's middleware can generate.
Netlify
A _headers file in the publish directory:
/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Content-Security-Policy-Report-Only: default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
Or the equivalent [[headers]] table in netlify.toml. Netlify sets HSTS itself on sites served over HTTPS.
Rollout without breaking client sites
- Check the current state of every site with the free headers and TLS checker, and note which layer already sets what.
- Deploy the safe four (
X-Content-Type-Options,Referrer-Policy,Permissions-Policy,X-Frame-Options) to all sites. If a client site is legitimately embedded elsewhere, useSAMEORIGINorframe-ancestorswith the specific origin instead ofDENY. - HSTS with
max-age=300; confirm every hostname under the domain works over HTTPS; raise to a year a week later. - CSP in report-only for at least a week of real traffic. Collect violations, add the analytics, payment, font and embed origins the site needs, then enforce.
- Test the flows that break: payment redirects and 3-D Secure pop-ups, OAuth logins, embedded maps, booking and chat widgets, and anything in an iframe.
- Rescan and keep the before-and-after for the client. ScanBastion's security headers scanning does this across every page and every client site.