Protection

7 min readUpdated

HTTP security headers: what to send, which values, and what breaks

Quick answer

HTTP security headers are response headers that tell the browser to enforce extra protections on your pages: HTTPS only (Strict-Transport-Security), limits on scripts and framing (Content-Security-Policy), no MIME sniffing (X-Content-Type-Options), less URL leakage (Referrer-Policy) and no access to browser features you do not use (Permissions-Policy). They cost nothing to send, but a wrong value can break logins, embeds or a whole subdomain, so roll them out in order.

01What are HTTP security headers?

They are instructions a server attaches to its responses, which the browser enforces for that page. None of them fixes a bug in your code, including the classes in the OWASP Top 10; they limit what an attacker can do with one, such as an injected script the policy refuses to run or a page that cannot be framed for clickjacking.

They only matter to browsers. A JSON API gains little from most of them, although X-Content-Type-Options and Strict-Transport-Security are cheap enough to send everywhere. They do not replace server-side defences such as CSRF tokens either; the CSRF guide explains why, and what is HTTP covers the protocol basics.

02Which security headers should I send? The list with values

The table lists the headers worth sending, a value to start with, and what goes wrong when the value does not fit the site. Priority weighs how much each one protects a typical site against the risk of turning it on.

HeaderStart withProtects againstWhat breaks if it is wrongPriority
Strict-Transport-Securitymax-age=31536000; includeSubDomainsDowngrade to plain HTTP, cookie theft on hostile networksA subdomain without working HTTPS becomes unreachable for the whole max-age, with no click-throughHigh, once HTTPS works on every hostname
Content-Security-PolicyReport-Only first, then a strict policy: script-src with a nonce and 'strict-dynamic', object-src 'none', base-uri 'none'Cross-site scripting, injected third-party code, framing via frame-ancestorsInline scripts, tag managers and embeds the policy does not allowHigh, needs a test period
X-Content-Type-OptionsnosniffBrowsers running an upload or text file as script or stylesheetScripts and stylesheets served with the wrong Content-TypeHigh, low risk
CSP frame-ancestors and X-Frame-Optionsframe-ancestors 'self', plus X-Frame-Options: SAMEORIGIN for old browsersClickjackingPartner embeds; two different X-Frame-Options values block framing everywhereMedium
Referrer-Policystrict-origin-when-cross-originFull URLs, including tokens in query strings, leaking to other sitesReports that rely on full referring paths from other originsMedium, low risk
Permissions-Policycamera=(), microphone=(), geolocation=()Injected or embedded code using powerful browser featuresFeatures your pages or embeds need, such as video callsMedium; only Chromium-based browsers apply it
Cross-Origin-Opener-Policysame-origin, or same-origin-allow-popups with OAuth or payment popupsCross-site windows keeping a handle on your page; side-channel attacksLogin and payment popups that talk back to their openerOptional
Cross-Origin-Resource-Policysame-site on private resourcesOther sites embedding your responsesAssets you want other sites to embedOptional
Cross-Origin-Embedder-Policyrequire-corp, only if you need cross-origin isolationMakes SharedArrayBuffer and precise timers safe to enableEvery third-party resource without CORP or CORSOnly when needed
X-XSS-Protection0, or do not send itNothing in current browsersThe old filters could create vulnerabilitiesRemove

03In what order should I roll out security headers?

Start with the headers that cannot break anything, then add the ones that need a test period, letting each step run in production for a while.

  • Step 1: X-Content-Type-Options and Referrer-Policy, after checking that scripts and stylesheets carry correct Content-Type values
  • Step 2: frame-ancestors 'self' in a CSP header, which works before you write a full policy, plus X-Frame-Options: SAMEORIGIN
  • Step 3: Permissions-Policy, switching off only features you know you do not use
  • Step 4: Strict-Transport-Security, ramped up as hstspreload.org recommends: max-age=300, then 604800 (a week), then 2592000 (a month), each with includeSubDomains, before a long max-age; request preload last, because removal takes months
  • Step 5: Content-Security-Policy-Report-Only for a few weeks, fix the reported sources, then send the same policy as Content-Security-Policy; both headers can run side by side
  • Step 6: COOP, CORP and COEP where the site's popups and embeds allow them

04How do I add security headers in nginx without losing them?

Two nginx defaults catch almost everyone. Without the always parameter, add_header only applies to 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 responses, so error pages go out bare. And add_header directives are inherited only if the current level defines none of its own: in testing, a location with one add_header Cache-Control line returned none of the seven headers its server block set.

The reliable pattern is one include file, used at server level and again in every location that adds a header. nginx 1.29.3 added add_header_inherit merge to append the parent's headers instead; Debian 12's nginx 1.22 does not have it. The CSP line below restricts framing, plugins and base URLs only; it is not yet a script policy.

If the application behind the proxy sends one of these headers too, the browser receives both copies, and the HTML standard says two different X-Frame-Options values block framing entirely. Hide the application's copy with proxy_hide_header, or remove it in the application.

Include file and location pattern (nginx -t and curl against nginx 1.22.1, Debian 12; send HSTS only from HTTPS server blocks)
# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "frame-ancestors 'self'; object-src 'none'; base-uri 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;

# in the server block
server {
    # listen 443 ssl; server_name and certificates as usual
    include snippets/security-headers.conf;

    location /assets/ {
        include snippets/security-headers.conf;   # needed: this location adds its own header
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    location /app/ {
        proxy_pass http://127.0.0.1:9000;
        proxy_hide_header X-Frame-Options;          # the app sends its own value
    }
}

05How do I check which security headers a site sends?

Ask the server directly. The commands below make normal GET requests and print only the response headers. Check an error page and a static asset as well, because that is where the inheritance and always pitfalls show up: all three counts should match. In a browser, the Network panel of the developer tools shows the same headers per request.

Header checks with curl 7.88.1 (replace example.com and the paths)
# security headers on the home page
curl -s -o /dev/null -D - https://example.com/ \
  | grep -i -E '^(strict-transport|content-security|x-content-type|x-frame|referrer-policy|permissions-policy|cross-origin)'

# count them on a page, an error page and an asset: the numbers should match
for p in / /does-not-exist /assets/app.css; do
  printf '%s ' "$p"; curl -s -o /dev/null -D - "https://example.com$p" \
    | grep -i -c -E '^(strict-transport|content-security|x-content-type|x-frame|referrer-policy|permissions-policy|cross-origin)'
done

06What breaks most often, and how do I recognise it?

Header problems show up as features that silently stop working, and the browser console almost always names the header responsible.

Symptom after a changeHeader responsibleHow to confirmFix
A subdomain shows a certificate warning that cannot be bypassedStrict-Transport-Security with includeSubDomainsThe warning names HSTS; see your connection is not privateGive the subdomain a valid certificate
Buttons do nothing or analytics stopContent-Security-PolicyThe console lists each blocked sourceAdd a nonce or hash, or allow the exact host; never add 'unsafe-inline' to silence it
A stylesheet or script stops loadingX-Content-Type-OptionsThe Network panel shows it served as text/plain or similarServe .js as text/javascript and .css as text/css
A partner embed shows an empty frameframe-ancestors or X-Frame-OptionsThe response carries DENY, SAMEORIGIN or two valuesList the partner in frame-ancestors; send X-Frame-Options once
External sign-in hangs after the popupCross-Origin-Opener-Policy: same-originThe popup cannot reach its openerUse same-origin-allow-popups on the opening page
Third-party images or fonts stop loadingCross-Origin-Embedder-Policy: require-corpThe console reports a COEP blockLoad them in CORS mode, or drop COEP
Headers missing on errors or assetsnginx add_header inheritance, or no alwaysThe curl count loop aboveAdd always; repeat the include per location

07Can a CDN, WAF or reverse proxy set security headers for me?

Yes, and it is often the easiest place, because one rule covers every application behind it. A reverse proxy, CDN or WAF can add headers an application forgets, strip version-revealing ones such as X-Powered-By (which cuts scanner noise, not exposure, as the nginx 1.18.0 triage explains), and cover the error pages it generates itself. Adding a header at the edge while an application fix is pending is a form of virtual patching.

What the edge cannot do is write a good Content Security Policy for you, because a strict policy depends on nonces the application generates per response. Edge headers must also agree with the application's: browsers enforce every CSP header they receive and extra policies can only restrict further, so an edge policy stacked on the application's can block what neither meant to block.

08Which cookie settings and old headers belong in the same review?

A header review is the right moment to check cookies and remove headers that no longer do anything.

  • Session cookies: Secure, HttpOnly and an explicit SameSite value, since only Chromium-based browsers treat a missing value as Lax; the __Host- prefix pins a cookie to one host
  • Remove X-Powered-By and version numbers in the Server header, which tell attackers which known vulnerabilities to try
  • Retire Expect-CT and Public-Key-Pins, which OWASP marks as not to be used, and replace Feature-Policy with Permissions-Policy
  • Send Cache-Control: no-store on responses with personal data, so shared caches never keep them

Questions

Frequently asked questions

Only for very old browsers. The HTML standard says that when a response carries an enforced Content Security Policy with frame-ancestors, browsers ignore X-Frame-Options. Sending both is harmless as long as they agree, for example frame-ancestors 'self' with SAMEORIGIN. Never send X-Frame-Options twice with different values, because browsers then refuse framing entirely, and note that ALLOW-FROM is obsolete and ignored.

No. MDN marks the header as deprecated and non-standard, and both MDN and OWASP recommend a Content Security Policy instead. If you send it at all, send X-XSS-Protection: 0, because the old browser filters it controlled could themselves create cross-site scripting vulnerabilities in otherwise safe pages. A policy that blocks inline scripts covers what the header once tried to do.

On nginx the usual reason is inheritance: add_header lines at server level are dropped in any location that defines its own add_header. The other is that, without the always parameter, nginx adds headers only to success and redirect responses, not to 404 or 500 pages. Put the headers in one include file, add always, and include the file in every location that sets headers of its own.

Only some. A Content Security Policy can be delivered in a meta tag, but frame-ancestors, sandbox and violation reporting do not work there. X-Frame-Options has no effect in a meta tag, and the HSTS standard tells browsers to ignore Strict-Transport-Security in one. Headers sent by the server or a proxy are the reliable option; a meta tag is a fallback for static hosting.

Sources

Sources and verification

The include file, the location pattern and proxy_hide_header were checked with nginx -t and curl 7.88.1 against nginx 1.22.1 in isolated network and mount namespaces: a location with its own add_header lost all seven headers until the include was repeated, the always parameter kept them on 404 responses, and a proxied application's X-Frame-Options was sent twice until hidden. Debian 12, October 2026.

  1. 01HTTP Security Response Headers Cheat Sheet · OWASP
  2. 02Content Security Policy Cheat Sheet (Report-Only, strict policy, meta tag limits) · OWASP
  3. 03RFC 6797, HTTP Strict Transport Security · RFC Editor
  4. 04HSTS preload list: submission requirements and deployment recommendations · hstspreload.org
  5. 05Module ngx_http_headers_module (add_header, always, add_header_inherit) · nginx.org
  6. 06HTML Standard: the X-Frame-Options header · WHATWG
  7. 07Fetch Standard: X-Content-Type-Options header · WHATWG
  8. 08Permissions-Policy header and browser compatibility · MDN
  9. 09Referrer-Policy header and browser defaults · MDN
  10. 10Cross-Origin-Opener-Policy · MDN
  11. 11Content-Security-Policy: multiple policies · MDN
  12. 12X-XSS-Protection · MDN

Last updated