Security Headers Checklist: How to Configure and Verify HTTP Security Headers
HTTP security headers are the cheapest high-impact hardening you can ship: a handful of response headers that stop clickjacking, MIME-sniffing, referrer leakage, and most XSS. This checklist tells you exactly which headers to set, what values to use, what each one breaks when you turn it on, and how to verify it.
What security headers actually protect
Security headers are browser-enforced policy. The browser reads them from the response and restricts what it will do with the page: which origins may execute script, whether the page may be framed, whether the connection may be downgraded to HTTP, how much referrer information leaves the page. They are not a substitute for server-side controls: no header stops SQL injection or template injection. They are the last line of defense that runs in the user's browser, and they stop whole attack classes at the client.
1. Server
Sends the response with CSP, HSTS and friends
2. Browser
Parses and stores the policy for the origin
3. Attacker
Injected script, framing or HTTP downgrade attempt
4. Browser
Blocks it and optionally reports the violation
| Header | Attack it stops | CWE |
|---|---|---|
Content-Security-Policy | Cross-site scripting, data injection, clickjacking | CWE-79, CWE-1021 |
Strict-Transport-Security | SSL stripping, protocol downgrade, cookie capture over plain HTTP | CWE-319 |
X-Content-Type-Options | MIME sniffing / MIME confusion (uploads served as executable HTML) | CWE-116 |
Referrer-Policy | Referrer leakage of full URLs (tokens, internal paths) to third parties | CWE-200 |
X-Frame-Options / CSP frame-ancestors | Clickjacking (UI redressing) | CWE-1021 |
Permissions-Policy | Abuse of camera/mic/geolocation after an injection or malicious iframe | Defense-in-depth |
Cross-Origin-Opener-Policy / Cross-Origin-Resource-Policy / Cross-Origin-Embedder-Policy | Cross-origin data theft via side channels (Spectre-class) | CWE-200 |
Cache-Control | Sensitive responses stored in shared or browser caches | CWE-524 |
Set-Cookie attributes | Session theft, fixation, CSRF via cookies | CWE-614, CWE-352 |
Everything below is grounded in the OWASP HTTP Security Response Headers Cheat Sheet and OWASP ASVS v4.0.3 V14.4 (HTTP Security Headers, requirements 14.4.1–14.4.7).
The checklist: P0 core headers (every site)
These six are non-negotiable for any public web application. If you only ship one batch of headers, ship these.
- Strict-Transport-Security (HSTS):
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload(OWASP recommendation; defined in RFC 6797). The browser will refuse plain-HTTP connections to your domain for two years. Only addpreloadonce you are certain every subdomain serves HTTPS: a preloaded domain cannot be removed quickly from the preload list (hstspreload.org requiresmax-ageof at least 31536000 plusincludeSubDomainsto accept a submission). - Content-Security-Policy: a strict policy with
object-src 'none',base-uri 'self',frame-ancestors 'none', and a source list that excludes'unsafe-inline'for scripts. See the dedicated section below. It is the header that needs the most care. - X-Content-Type-Options:
X-Content-Type-Options: nosniff. Blocks MIME sniffing, so an attacker-uploaded file can never be rendered as executable HTML (ASVS 14.4.4). - Referrer-Policy:
Referrer-Policy: strict-origin-when-cross-origin. Sends the full URL to same-origin destinations, only the origin to cross-origin ones, and nothing on HTTPS→HTTP downgrades. This is the modern browser default; set it explicitly so behavior does not depend on client version (ASVS 14.4.6). - X-Frame-Options:
X-Frame-Options: DENY. The legacy clickjacking control, still useful for older browsers; CSPframe-ancestorssupersedes it where CSP Level 2 is supported (ASVS 14.4.7). Set both; they do not conflict. - Content-Type with charset:
Content-Type: text/html; charset=utf-8. An explicit charset prevents UTF-7-style encoding-based XSS and ensures the declared type always matches the body (ASVS 14.4.1).
Plus one hygiene rule: strip fingerprinting headers. Server and X-Powered-By advertise your stack to attackers. Remove them or set non-informative values (OWASP: set Server: webserver or nothing). This is minor (attackers fingerprint you anyway), but it is free.
The checklist: P1 hardening (most sites)
- Permissions-Policy:
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(). Disables browser features your site does not use, for your own pages and any iframe content. If an injection or a malicious iframe later tries to enable the camera or geolocation, the browser refuses. Rarely breaks anything; if a feature is genuinely needed, allow only your origin:camera=(self). - Cross-Origin-Opener-Policy:
Cross-Origin-Opener-Policy: same-origin. Isolates your page in its own browsing context group, so a cross-origin page opened from yours (or opening yours) cannot hold a handle to yourwindowobject. Directly reduces the attack surface of cross-origin data theft. - Cross-Origin-Resource-Policy:
Cross-Origin-Resource-Policy: same-site. Tells the browser which origins may include your resources; prevents a cross-origin page from loading your responses into its process (relevant for Spectre-class side channels). - Cache-Control for sensitive responses:
Cache-Control: no-storefor anything containing personal or session data;privatefor user-specific but cacheable content. Do not rely on defaults, and note thatno-cachedoes not prevent storage. It only forces revalidation (OWASP). - Set-Cookie attributes: every session cookie must carry
Secure,HttpOnly, andSameSite=Lax(orStrictwhere the UX tolerates it).SameSite=Laxis the modern browser default; set it explicitly and verify it in theSet-Cookieheader (OWASP Session Management Cheat Sheet). Cookie flags do not stop an adversary-in-the-middle proxy from relaying a live session, even one authenticated with passkeys.
The checklist: P2 opt-in (with real costs)
- Cross-Origin-Embedder-Policy: require-corp: the strongest cross-origin isolation control, and the most expensive: the page can no longer load any cross-origin resource that does not explicitly opt in via CORP or CORS headers. One third-party font, image CDN, or iframe without the right headers and it silently fails to load. Enable only when you control (or can enumerate) every resource the page loads, and prefer
credentiallessas a stepping stone. - CSP
upgrade-insecure-requests: instructs the browser to rewrite every HTTP subresource to HTTPS. Harmless for a fully-HTTPS site; genuinely useful during migrations off legacy HTTP URLs. - Trusted Types:
require-trusted-types-for 'script'(CSP directive, plus thetrusted-typespolicy allowlist). Locks down DOM XSS sinks so only typed, non-spoofable values reachinnerHTML-class sinks. High value for apps with a lot of DOM manipulation; requires refactoring code that assigns strings to sinks.
And two headers you should not set:
- X-XSS-Protection: set
X-XSS-Protection: 0or omit it. The legacy browser filter is known to introduce XSS vulnerabilities in otherwise safe sites (MDN), and CSP replaces it. Many scanners still demand this header; the correct answer is a working CSP, not a dangerous legacy filter. - Expect-CT: do not use it. Certificate Transparency is enforced by default in modern browsers; the header is deprecated and MDN recommends removing it.
Content-Security-Policy: the header that needs care
CSP is the highest-value security header and the one most likely to break your site. A workable strict policy for a static SPA (self-hosted assets, no inline scripts) looks like this:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'
default-src 'self': the fallback for every fetch directive not listed; everything loads from your origin unless explicitly allowed (MDN).object-src 'none': kills<object>/<embed>vectors (Flash-class legacy plugins) outright.base-uri 'self': stops base-tag injection from rewriting every relative URL on the page.frame-ancestors 'none': the modern clickjacking control; replacesX-Frame-Optionsin CSP Level 2+ browsers.style-src 'unsafe-inline': a deliberate allowance, because framework-renderedstyle="..."attributes are inline styles. Removing it breaks styling before it protects anything. Never grant the equivalent inscript-src.
If your build injects inline scripts (Vite legacy polyfills, analytics snippets, CSP-required inline styles), use nonces or hashes instead of 'unsafe-inline': a per-request nonce on the <script> tag matching script-src 'nonce-...', or a 'sha256-...' hash of the exact inline content. Note that browsers ignore 'unsafe-inline' for a directive when a nonce or hash is present, so a nonce-based policy is strictly stronger. Inline application/ld+json (JSON-LD) blocks are data, not executable scripts, and are not governed by script-src.
Rollout rule: ship every new CSP first as Content-Security-Policy-Report-Only with a report-uri (or the newer report-to), collect violations for at least one release cycle, fix what trips, then enforce. Every blocking change should follow this pattern: the header that breaks your checkout is a self-inflicted outage.
Decision matrix: what each header breaks
| Header | Recommended value | What breaks when enabled | Verify |
|---|---|---|---|
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | Preloaded domains cannot be un-preloaded quickly; an expired/misconfigured certificate bricks access for the full max-age. Roll out max-age incrementally first. | curl -sI on the apex and one subdomain |
Content-Security-Policy | strict policy (section above) | Inline scripts, eval(), and third-party widgets break. Use Report-Only first. | Browser console; report-uri endpoint |
X-Content-Type-Options | nosniff | Responses whose Content-Type does not match their body stop rendering, which is the point. | curl -sI on HTML, JS, CSS, images |
Referrer-Policy | strict-origin-when-cross-origin | Cross-origin referrer analytics lose the full path (see only origin). | curl -sI |
X-Frame-Options | DENY | Legitimate embedding of your pages in third-party frames. If embedding is a requirement, use CSP frame-ancestors with an explicit allowlist instead. | curl -sI |
Permissions-Policy | camera=(), microphone=(), geolocation=(), payment=(), usb=() | Disabled features fail with a clear permission error. Rarely breaks anything real. | curl -sI |
Cross-Origin-Opener-Policy | same-origin | Popup flows that rely on window.opener (some OAuth popups, SSO windows) break: the cross-origin popup gets a null opener. Use same-origin-allow-popups if needed. | Functional test of every popup flow |
Cross-Origin-Resource-Policy | same-site | Cross-origin sites can no longer embed your resources (images, fonts, JSON) without an explicit CORP/CORS opt-in. | Functional test of embedded resources |
Cross-Origin-Embedder-Policy | require-corp | Every cross-origin subresource without CORP/CORS headers fails to load. Only for apps that control all resources. | Full-page functional pass; network tab |
Cache-Control | no-store on sensitive responses | Slower repeat loads for those URLs. | curl -sI on authenticated routes |
Set-Cookie flags | Secure; HttpOnly; SameSite=Lax | SameSite=Strict drops cookies on cross-site navigations (broken deep links from other sites). | Login flow + cross-site navigation test |
Configuration examples (real values)
Cloudflare Pages / Workers Static Assets: _headers file
Both Cloudflare Pages and Workers Static Assets support a plain-text _headers file in the assets directory (Cloudflare docs). Rules are a path followed by indented header lines; this is the file we deploy with proksiabel.ee:
# pub/_headers — shipped next to the built assets /* Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self' Strict-Transport-Security: max-age=63072000; includeSubDomains; preload X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin X-Frame-Options: DENY Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=() Cross-Origin-Opener-Policy: same-origin Cross-Origin-Resource-Policy: same-site # binary artifacts must download, never render inline /full_exploit_final_v2_release.zip Content-Disposition: attachment Content-Type: application/octet-stream
Note the trade-off on style-src 'unsafe-inline': framework-rendered style="..." attributes are inline styles, so a fully strict style policy breaks this site before it hardens anything. The scripts stay strict with script-src 'self', because the Vite build emits only external, hashed module scripts, so no inline-script allowance is needed.
Nginx
server {
listen 443 ssl;
# ... certs, root, etc ...
# 'always' sends the header on error pages too (4xx/5xx)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-site" always;
server_tokens off; # drop the version from the Server header
}Nginx gotcha: add_header directives are not inherited by a child block that declares its own add_header. If a location block adds any header of its own, re-declare the security headers there or the child response loses them.
Caddy
example.com {
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
X-Frame-Options "DENY"
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
Cross-Origin-Opener-Policy "same-origin"
Cross-Origin-Resource-Policy "same-site"
-Server
}
}Express (Node.js): helmet
import helmet from 'helmet';
app.use(
helmet({
contentSecurityPolicy: {
directives: {
'default-src': ["'self'"],
'script-src': ["'self'"],
'style-src': ["'self'", "'unsafe-inline'"],
'img-src': ["'self'", 'data:'],
'font-src': ["'self'"],
'connect-src': ["'self'"],
'object-src': ["'none'"],
'base-uri': ["'self'"],
'frame-ancestors': ["'none'"],
'form-action': ["'self'"],
},
},
hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
crossOriginOpenerPolicy: { policy: 'same-origin' },
crossOriginResourcePolicy: { policy: 'same-site' },
permissionsPolicy: {
features: { camera: [], microphone: [], geolocation: [], payment: [], usb: [] },
},
})
);GitHub Pages: cannot set custom headers
GitHub Pages does not support custom response headers (long-standing platform limitation, tracked in GitHub community discussions). A Pages-hosted site cannot ship CSP, HSTS, or any of the headers above from the platform itself. If security headers are a requirement (and for a security consultancy they are), the hosting layer must be one that supports them (Cloudflare Pages/Workers, an origin server, a CDN transform rule). This is a platform decision, not a config detail.
Verification
Every header is verifiable with one curl command. The expected output for a hardened site:
$ curl -sI https://example.com/ | grep -iE 'strict-transport|content-security|x-content-type|referrer-policy|x-frame-options|permissions-policy|cross-origin' strict-transport-security: max-age=63072000; includeSubDomains; preload content-security-policy: default-src 'self'; script-src 'self'; ... x-content-type-options: nosniff referrer-policy: strict-origin-when-cross-origin x-frame-options: DENY permissions-policy: camera=(), microphone=(), geolocation=(), payment=(), usb=() cross-origin-opener-policy: same-origin cross-origin-resource-policy: same-site
For a CI gate, fail the build when a required header is missing:
#!/usr/bin/env bash
# verify-headers.sh <url> — exits 1 if any required header is missing
set -euo pipefail
url="${1:?usage: $0 <url>}"
required=(strict-transport-security content-security-policy x-content-type-options referrer-policy)
for h in "${required[@]}"; do
if ! curl -sI "$url" | grep -qi "^$h:"; then
echo "MISSING: $h" >&2
exit 1
fi
done
echo "OK: all required security headers present"Run it in CI against a staging deploy (or vite preview locally) before every release. Note that curl against a CDN shows the CDN's edge headers; test with and without ?cache-bust= to catch cached responses that predate the header change.
Online checkers worth running at least once per release: securityheaders.com (grades your header set) and the MDN HTTP Observatory (the successor to the retired Mozilla Observatory; the old JSON API was shut down October 31, 2024). Both only scan what they can reach. They complement, not replace, the curl check.
Worked example: auditing proksiabel.ee
While writing this guide we ran the verification against our own site (2026-08-27). The live origin is currently GitHub Pages, and the security-relevant headers it ships are:
$ curl -sI https://proksiabel.ee/ | grep -iE 'server:|access-control|content-security|strict-transport|x-content-type|referrer-policy|x-frame-options|permissions-policy' server: GitHub.com access-control-allow-origin: *
Result: zero security headers and a Access-Control-Allow-Origin: * on an HTML page (harmless for static HTML, but the kind of default that quietly becomes a problem when an API endpoint is added later). CSP, HSTS, nosniff, Referrer-Policy, X-Frame-Options, Permissions-Policy: all absent, because GitHub Pages cannot set them. The fix is the _headers file above deployed to the Cloudflare origin, after which the check returns the full hardened set. Auditing your own site first is the honest way to write this checklist, and the reason this guide ships with verification commands instead of vibes.
Rollout order
- Baseline: run the curl check and securityheaders.com against every route type (HTML, API, static assets). Record what is missing.
- HSTS first, incrementally:
max-age=300→86400→63072000, then addincludeSubDomains, and only after all subdomains provably serve HTTPS, submit for preload. - CSP in Report-Only for at least one release cycle; fix violations; enforce.
- Static headers (nosniff, Referrer-Policy, X-Frame-Options, Permissions-Policy, COOP, CORP) in one change; verify every route and the SPA fallback path.
- Add the CI gate so a missing P0 header fails the build from now on.
Key takeaways
- Security headers are browser-enforced policy: they stop whole attack classes (clickjacking, MIME confusion, referrer leakage, most XSS) in the user's browser, independent of your application code.
- P0 is six headers: HSTS, CSP, nosniff, Referrer-Policy, X-Frame-Options, Content-Type-with-charset, plus stripping Server/X-Powered-By. Everything else is P1/P2 with real trade-offs (OWASP, ASVS 14.4).
- CSP is the only header that routinely breaks things. Ship it Report-Only first, use nonces/hashes instead of
'unsafe-inline'for scripts, and keepobject-src 'none'andbase-uri 'self'in every policy. - Do not set X-XSS-Protection (it can create XSS) or Expect-CT (deprecated). Scanner pressure is not a reason to ship a dangerous legacy header.
- Verification is one curl per header, and a CI gate that fails the build on a missing P0 header. If your hosting platform cannot set headers (GitHub Pages), change the platform; it is a hard requirement, not a nice-to-have.
Frequently asked questions
- Which HTTP security headers should every site set?
- The P0 set is six: Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options: nosniff, Referrer-Policy, X-Frame-Options (or CSP frame-ancestors), and Content-Type with an explicit charset, plus removing Server and X-Powered-By version banners.
- Should I still set X-XSS-Protection?
- No. Set X-XSS-Protection: 0 or omit it. The legacy browser XSS filter it controlled has been removed from modern browsers and was known to introduce vulnerabilities of its own; Content-Security-Policy is the replacement.
- How do you roll out a Content-Security-Policy without breaking the site?
- Deploy it first as Content-Security-Policy-Report-Only, collect the violation reports, fix or allowlist legitimate sources, then switch to enforcement. Prefer nonces or hashes over 'unsafe-inline' for scripts, and add object-src 'none' and base-uri 'self'.
- How do you verify security headers are actually deployed?
- Request the live URL with curl -sI and check each header value, including on redirects and error pages, then add the same check as a CI gate so a platform or config change cannot silently drop them.
Kokkuvõte eesti keeles
HTTP turvapäised (ingl k security headers) on odavaim ja kiireim tugevdamine, mida veebirakendusele lisada: brauser loeb vastuse päiseid ja piirab vastavalt lehe käitumist. Kohustuslikud (P0): Strict-Transport-Security (RFC 6797, soovitus max-age=63072000; includeSubDomains; preload), Content-Security-Policy (XSS-i vastu; kõige keerulisem päis: esmalt Report-Only režiimis, mitte kunagi 'unsafe-inline' skriptidele), X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, X-Frame-Options: DENY (klikivarguse vastu) ja Content-Type koos charset-iga. Edasijõudnutele (P1): Permissions-Policy, Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Resource-Policy: same-site ja Cache-Control: no-store tundlikele vastustele. Ära pane kunagi X-XSS-Protection ega Expect-CT. Kontrolli iga päist käsuga curl -sI ja lisa CI-sse kontroll, mis ehituse ebaõnnestuma paneb, kui mõni P0-päis puudub. Oluline piirang: GitHub Pages ei võimalda kohandatud päiseid üldse, seega tuleb hostimisplatvormi vahetada (näiteks Cloudflare Pages / Workers static assets, kus päised pannakse _headers failiga). Täielik loend, konfiguratsiooninäited (Cloudflare, Nginx, Caddy, Express) ja otsustustabel on ülal inglise keeles.
Sources
- OWASP: HTTP Security Response Headers Cheat Sheet (recommended values for all headers covered here)
- OWASP Secure Headers Project
- OWASP ASVS v4.0.3: V14.4 HTTP Security Headers (14.4.1–14.4.7)
- RFC 6797: HTTP Strict Transport Security (HSTS)
- W3C: Content Security Policy Level 3 and the MDN CSP reference
- MDN: Content Security Policy guide (nonces, hashes, deployment strategies)
- HSTS Preload List: submission requirements
- Cloudflare Pages docs: the _headers file and Workers Static Assets docs
- GitHub Pages: no custom headers (community discussion)
- MDN: X-XSS-Protection (why to disable it) and Expect-CT (deprecated)