Skip to main content

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. 1. Server

    Sends the response with CSP, HSTS and friends

  2. 2. Browser

    Parses and stores the policy for the origin

  3. 3. Attacker

    Injected script, framing or HTTP downgrade attempt

  4. 4. Browser

    Blocks it and optionally reports the violation

Where security headers act: the server declares policy once, the browser enforces it on every page load.
HeaderAttack it stopsCWE
Content-Security-PolicyCross-site scripting, data injection, clickjackingCWE-79, CWE-1021
Strict-Transport-SecuritySSL stripping, protocol downgrade, cookie capture over plain HTTPCWE-319
X-Content-Type-OptionsMIME sniffing / MIME confusion (uploads served as executable HTML)CWE-116
Referrer-PolicyReferrer leakage of full URLs (tokens, internal paths) to third partiesCWE-200
X-Frame-Options / CSP frame-ancestorsClickjacking (UI redressing)CWE-1021
Permissions-PolicyAbuse of camera/mic/geolocation after an injection or malicious iframeDefense-in-depth
Cross-Origin-Opener-Policy / Cross-Origin-Resource-Policy / Cross-Origin-Embedder-PolicyCross-origin data theft via side channels (Spectre-class)CWE-200
Cache-ControlSensitive responses stored in shared or browser cachesCWE-524
Set-Cookie attributesSession theft, fixation, CSRF via cookiesCWE-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.

  1. 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 add preload once you are certain every subdomain serves HTTPS: a preloaded domain cannot be removed quickly from the preload list (hstspreload.org requires max-age of at least 31536000 plus includeSubDomains to accept a submission).
  2. 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.
  3. 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).
  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).
  5. X-Frame-Options: X-Frame-Options: DENY. The legacy clickjacking control, still useful for older browsers; CSP frame-ancestors supersedes it where CSP Level 2 is supported (ASVS 14.4.7). Set both; they do not conflict.
  6. 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 your window object. 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-store for anything containing personal or session data; private for user-specific but cacheable content. Do not rely on defaults, and note that no-cache does not prevent storage. It only forces revalidation (OWASP).
  • Set-Cookie attributes: every session cookie must carry Secure, HttpOnly, and SameSite=Lax (or Strict where the UX tolerates it). SameSite=Lax is the modern browser default; set it explicitly and verify it in the Set-Cookie header (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 credentialless as 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 the trusted-types policy allowlist). Locks down DOM XSS sinks so only typed, non-spoofable values reach innerHTML-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: 0 or 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; replaces X-Frame-Options in CSP Level 2+ browsers.
  • style-src 'unsafe-inline': a deliberate allowance, because framework-rendered style="..." attributes are inline styles. Removing it breaks styling before it protects anything. Never grant the equivalent in script-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

HeaderRecommended valueWhat breaks when enabledVerify
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadPreloaded 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-Policystrict policy (section above)Inline scripts, eval(), and third-party widgets break. Use Report-Only first.Browser console; report-uri endpoint
X-Content-Type-OptionsnosniffResponses whose Content-Type does not match their body stop rendering, which is the point.curl -sI on HTML, JS, CSS, images
Referrer-Policystrict-origin-when-cross-originCross-origin referrer analytics lose the full path (see only origin).curl -sI
X-Frame-OptionsDENYLegitimate 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-Policycamera=(), microphone=(), geolocation=(), payment=(), usb=()Disabled features fail with a clear permission error. Rarely breaks anything real.curl -sI
Cross-Origin-Opener-Policysame-originPopup 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-Policysame-siteCross-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-Policyrequire-corpEvery cross-origin subresource without CORP/CORS headers fails to load. Only for apps that control all resources.Full-page functional pass; network tab
Cache-Controlno-store on sensitive responsesSlower repeat loads for those URLs.curl -sI on authenticated routes
Set-Cookie flagsSecure; HttpOnly; SameSite=LaxSameSite=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

  1. Baseline: run the curl check and securityheaders.com against every route type (HTML, API, static assets). Record what is missing.
  2. HSTS first, incrementally: max-age=3008640063072000, then add includeSubDomains, and only after all subdomains provably serve HTTPS, submit for preload.
  3. CSP in Report-Only for at least one release cycle; fix violations; enforce.
  4. Static headers (nosniff, Referrer-Policy, X-Frame-Options, Permissions-Policy, COOP, CORP) in one change; verify every route and the SPA fallback path.
  5. 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 keep object-src 'none' and base-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

Related Guides

Infrastructure & Configuration Hardening

All security guides by attack family