Security
Setting Security Headers in 2026: CSP, HSTS, COEP, and What Actually Matters
A practical guide to the security headers worth deploying in 2026 — strict-dynamic CSP with nonces, HSTS preload, isolation headers, and Cloudflare/Nginx/Caddy snippets.
Security headers are one of those topics where the recommendations have been broadly stable for years and yet most production sites still get them wrong. We audit hundreds of domains a month at CheckFast, and the modal score is a B — usually because someone shipped a Content-Security-Policy that allows unsafe-inline and unsafe-eval, an HSTS header without preload, and zero isolation headers despite the page touching SharedArrayBuffer.
This post is the 2026 update to the canonical "set your security headers" advice. We will cover the headers that actually move the needle — CSP, HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and the cross-origin isolation suite (COEP, COOP, CORP) — with concrete configs for Nginx, Caddy, and Cloudflare. We will skip the deprecated and ineffective ones (X-XSS-Protection, X-Frame-Options where CSP already covers it).
The goal is to give you a config snippet you can paste, an explanation of why each header matters, and the gotchas that break things in production. By the end you should be able to score A+ on Mozilla Observatory and securityheaders.com, with a CSP that does not require unsafe-inline, an HSTS submitted to the preload list, and isolation headers that unlock SharedArrayBuffer if you need it.
Content-Security-Policy: the only header that matters more than HSTS
CSP is the most powerful and most-misconfigured security header. A correctly-configured CSP eliminates entire classes of XSS — including those introduced by future code changes you did not write. An incorrectly-configured CSP either breaks the site or, more commonly, is so loose it provides no protection at all.
The 2026 best-practice is strict-dynamic with per-request nonces. This means: every script you include in your HTML gets a nonce attribute matching a random value emitted in the CSP header, and the policy uses 'strict-dynamic' to let those scripts load anything they need transitively. The result is a single allowlist anchor (the nonce on your initial scripts) that propagates trust to dependencies without requiring you to enumerate every CDN.
The alternative — script-src 'self' https://cdn.example.com https://www.googletagmanager.com ... — is the legacy pattern. It works, but it requires maintaining an allowlist that grows every time a new third party is added, and it does not protect against XSS where attacker-controlled data appears in a pointing at an allowlisted domain.
The cost of strict-dynamic is that every inline script (and every dynamically-injected script) needs a nonce. Frameworks help here: Next.js has next/script with nonce support, SvelteKit has csp config in svelte.config.js, Astro has @astrojs/security middleware. Without framework help, you generate a nonce per request server-side and inject it into both the CSP header and every tag.
// Next.js middleware.ts — emits a per-request nonce for strict-dynamic CSP.
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const csp = [
`default-src 'self'`,
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
`style-src 'self' 'nonce-${nonce}'`,
`img-src 'self' data: https:`,
`font-src 'self' data:`,
`connect-src 'self' https:`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`upgrade-insecure-requests`,
].join("; ");
const response = NextResponse.next({
request: { headers: new Headers(request.headers) },
});
response.headers.set("Content-Security-Policy", csp);
response.headers.set("x-nonce", nonce); // read by Server Components
return response;
}CheckFast SSL Checker scores your headers, validates HSTS preload eligibility, and surfaces CSP gaps.
Check your security headersRolling out CSP without breaking the site
The standard rollout pattern is: deploy in report-only mode, monitor for 2-4 weeks, fix the violations, then enforce. The Content-Security-Policy-Report-Only header has identical semantics to Content-Security-Policy but only reports violations instead of blocking — perfect for staging the rollout.
Pair report-only with a report-uri (deprecated but widely supported) or report-to (the modern equivalent) directive. Browsers will POST violation reports to the configured endpoint as JSON. Run the endpoint as a small ingestion service (Postgres + a tiny API, or a managed reporting service like report-uri.com or Sentry's CSP reports).
Real CSP rollouts always surface surprises. Common ones: third-party iframes that inject scripts (analytics, chat widgets); developer-tools extensions that inject content scripts (these can be ignored — they are violations from the user's browser, not your code); A/B test scripts that inject inline styles; legacy code that uses eval() directly or via new Function().
Once the report-only run is clean for a week, switch to enforcement. Keep the report-uri active for ongoing monitoring — new violations from third-party services or new code paths are common, and silent breakage in production is expensive.
HSTS and the preload list
HSTS (HTTP Strict Transport Security, RFC 6797) tells browsers to only connect to your domain over HTTPS, never plain HTTP. Once a browser has seen the header once, it caches the policy for the configured max-age duration and refuses to make HTTP connections regardless of what links or redirects say.
The minimum useful HSTS header is Strict-Transport-Security: max-age=31536000; includeSubDomains. The max-age is one year in seconds. The includeSubDomains extends the policy to every subdomain — necessary for full protection but means you cannot have any HTTP-only subdomains (rare but worth checking before enabling).
For maximum protection, submit your domain to the HSTS preload list at hstspreload.org. Once accepted, every Chromium, Firefox, and Safari browser ships with your domain hard-coded as HTTPS-only. The submission requirements are strict: 2-year max-age, includeSubDomains, the preload directive, and HTTPS on every subdomain. Removal from the preload list takes 12+ months — submit only after careful verification.
Preload submission is the right answer for any domain that has been HTTPS-only for at least a few months. The asymmetric protection (any user who has never visited before is still vulnerable to a downgrade attack on first visit, until preload covers it) is the canonical reason to push for preload status.
# /etc/nginx/conf.d/security-headers.conf
# Apply to every server block via include directive in nginx.conf.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
# Note: CSP is per-route since it requires per-request nonce — emit from app, not Nginx.X-Content-Type-Options, Referrer-Policy, and Permissions-Policy
These three are stable, well-supported, and nearly free to deploy. There is no good reason not to set them.
X-Content-Type-Options: nosniff prevents browsers from "sniffing" the response and overriding your declared Content-Type. Without it, a browser might decide your text/plain endpoint is actually JavaScript and execute it. The mitigation is one line of config; the alternative is occasional MIME-confusion bugs that produce CVEs.
Referrer-Policy: strict-origin-when-cross-origin controls how much information the browser sends in the Referer header. The default for most browsers is now this exact value, but setting it explicitly prevents older browsers from leaking full URLs (with query strings) to third parties. Use no-referrer if you have specific privacy requirements; strict-origin-when-cross-origin is the right balance for most sites.
Permissions-Policy (formerly Feature-Policy) controls which browser features can be used by your page and embedded iframes. Sensible defaults: disable geolocation, microphone, camera, USB, and serial unless you actually use them. The header is opt-in for everything — features not listed are allowed. The syntax is dense but standard: geolocation=(), microphone=(), camera=() disables all three for self and cross-origin.
For sites that legitimately need a feature, scope it: microphone=(self) lets your origin use the microphone but blocks embedded iframes. camera=(self "https://meet.example.com") allows yourself and a specific origin.
Cross-origin isolation: COEP, COOP, CORP
Cross-origin isolation is the post-Spectre/Meltdown answer to "how do we let pages use SharedArrayBuffer without enabling speculative-execution attacks". The model: a page can opt into a high-isolation mode where the browser guarantees no cross-origin window access and no untagged cross-origin resource loads. In exchange, the page gets back access to SharedArrayBuffer, high-resolution timers, and a few other capabilities.
COOP (Cross-Origin-Opener-Policy) controls window.opener access from cross-origin pages. The values are same-origin (block all cross-origin window access), same-origin-allow-popups (allow popups but block opener access from them), and unsafe-none (default, no protection). Most production sites should use same-origin.
COEP (Cross-Origin-Embedder-Policy) controls cross-origin resources you embed. The values are require-corp (every cross-origin resource must explicitly opt in via CORP header) and credentialless (a more permissive option introduced in 2022). Setting COEP require-corp is the strict choice; credentialless is the practical compromise that lets you embed third-party CDNs without coordinating CORP headers.
CORP (Cross-Origin-Resource-Policy) is set on resources you serve, not on pages you ship. It tells which other origins can embed your resource. same-origin is the strictest; cross-origin is the most permissive. Static assets that should be embeddable from anywhere (logo images, fonts) need cross-origin; sensitive endpoints should use same-origin.
Most sites do not need cross-origin isolation. The headers are only required if you use SharedArrayBuffer, high-resolution timers, or specific WebAssembly threading APIs. If you do not, COOP same-origin is the only one of the three you need to set, and it is essentially free.
Configuration snippets for Nginx, Caddy, and Cloudflare
Nginx uses add_header directives. Place them in a single include file and reference from each server block — this guarantees they apply uniformly. Note the always flag, which forces the headers on error responses too (without always, 4xx and 5xx responses skip them).
Caddy sets headers via the header directive. The Caddyfile syntax is much more compact than Nginx and supports per-route overrides naturally. Caddy 2 also automatically sets HSTS when it issues a TLS certificate — you only need to override if you want non-default settings.
Cloudflare can set headers via Transform Rules (free tier) or Workers (paid tier). Transform Rules are sufficient for static headers; Workers are needed for per-request CSP nonces. Cloudflare's "Security" → "HSTS" panel gives you a one-click HSTS deployment with sane defaults — the easiest path for any site already on Cloudflare.
If you are running a CDN in front of an origin, you have a choice about where to set headers. The CDN is more reliable (every response from the CDN is protected, including ones served from cache) but the origin has more context (it can vary the CSP per route, per user, per nonce). For a typical SaaS, set static headers (HSTS, X-Content-Type-Options, Permissions-Policy) at the CDN, and dynamic headers (CSP with nonce) at the origin.
# Caddy example with security headers and per-route CSP override
(security-headers) {
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
Cross-Origin-Opener-Policy "same-origin"
# remove server identification
-Server
}
}
example.com {
import security-headers
reverse_proxy localhost:3000
}
# An admin subdomain with a stricter CSP
admin.example.com {
import security-headers
header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'"
reverse_proxy localhost:3001
}Headers to skip in 2026
X-XSS-Protection is deprecated. Modern browsers ignore it, and it actively hurt security in some cases (the browser's reflective XSS filter could be tricked into causing problems). Remove it if you have it.
X-Frame-Options is superseded by CSP's frame-ancestors directive. Setting both is fine for legacy browser support but unnecessary for modern stacks. If you have CSP, drop X-Frame-Options.
Public-Key-Pins (HPKP) was deprecated and removed from browsers in 2018-2019. Do not deploy it. The replacement (Expect-CT) was also deprecated in 2024 because Certificate Transparency is now mandatory for all certs. Just remove both.
Server, X-Powered-By, X-AspNet-Version: information disclosure headers that tell attackers what stack you are running. Suppress them. Nginx removes Server with a custom build (or more_clear_headers; Caddy removes it natively (-Server in headers); Cloudflare auto-strips them.
Verifying with Mozilla Observatory and securityheaders.com
After deployment, verify externally. Mozilla Observatory (observatory.mozilla.org) gives a letter grade with detailed scoring. SecurityHeaders.com gives a similar grade with a simpler UI. Hardenize.com is more thorough but slower. CheckFast's SSL checker covers headers as part of a broader security audit.
Aim for A+ on both. A+ requires: HSTS with preload, CSP without unsafe-inline (or with nonce-based strict-dynamic), X-Content-Type-Options, Referrer-Policy, COOP, and a Permissions-Policy listing at least one disabled feature.
B-grade results almost always trace to one of: CSP with unsafe-inline and unsafe-eval; HSTS without preload directive; missing X-Content-Type-Options. Each is a one-line config fix. The fact that B-grade is the production median for SaaS sites in 2026 is mostly a story of teams shipping something good enough and not iterating.
Run the validators monthly. New CSP violations and new third-party integrations both produce regressions, and the validators catch them within minutes. Wire securityheaders.com into your CI as a smoke test if you want continuous verification.
Step-by-step config for HSTS, CSP, and the rest — Nginx, Caddy, and Cloudflare snippets included.
Fix missing security headersFrequently asked
Yes. GTM is the canonical reason teams give up on CSP but the strict-dynamic + nonce approach handles GTM cleanly — you nonce the GTM bootstrap script and strict-dynamic propagates trust to whatever GTM injects. The Google Tag Assistant lets you test that GTM still works under your CSP. Plan for 1-2 hours of debugging the first time.
Start with 60 (one minute) for one day to confirm nothing breaks. Bump to 86400 (one day) for a week. Bump to 2592000 (one month) for a month. If still clean set 31536000 (one year) and submit to the preload list. The escalation pattern lets you back out quickly if you discover an HTTP-only subdomain you forgot about.
Yes. script-src 'self' https://cdn.example.com works fine if all your scripts are external. The nonce pattern is needed when you have inline scripts or use a framework that injects them (most React/Vue/Svelte frameworks do for hydration). For static sites with all-external scripts simpler CSP is fine.
Most security headers are meant for HTML responses not JSON APIs. HSTS X-Content-Type-Options and CORP still apply to APIs. CSP COOP COEP and Referrer-Policy are HTML-specific and have no effect on JSON. Setting them on APIs does no harm but is not required.
Deploy in report-only mode (Content-Security-Policy-Report-Only header) for 2-4 weeks. Monitor reports. Fix violations. Switch to enforcement once clean. The report-only mode is identical to enforcement except violations are logged instead of blocked — perfect for staging.
No. Headers add a few hundred bytes per response — negligible. The exception is HSTS preload which forces an HTTPS upgrade for first-time visitors. That redirect is a one-time cost that protects against downgrade attacks; the right trade-off in 2026.
Related reading
Security
SSL Renewal Strategies: Comparing Let's Encrypt, ZeroSSL, and Caddy Auto-Renewal
14 min read
Email deliverability
DMARC From Zero to Reject: A Step-by-Step Rollout
13 min read
SEO
Redirect Anti-Patterns and Best Practices for SEO
11 min read
Performance
The Core Web Vitals 2026 Guide: How to Hit Green on LCP, INP, and CLS
12 min read