Protection
7 min readUpdatedHTTP 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.
| Header | Start with | Protects against | What breaks if it is wrong | Priority |
|---|---|---|---|---|
| Strict-Transport-Security | max-age=31536000; includeSubDomains | Downgrade to plain HTTP, cookie theft on hostile networks | A subdomain without working HTTPS becomes unreachable for the whole max-age, with no click-through | High, once HTTPS works on every hostname |
| Content-Security-Policy | Report-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-ancestors | Inline scripts, tag managers and embeds the policy does not allow | High, needs a test period |
| X-Content-Type-Options | nosniff | Browsers running an upload or text file as script or stylesheet | Scripts and stylesheets served with the wrong Content-Type | High, low risk |
| CSP frame-ancestors and X-Frame-Options | frame-ancestors 'self', plus X-Frame-Options: SAMEORIGIN for old browsers | Clickjacking | Partner embeds; two different X-Frame-Options values block framing everywhere | Medium |
| Referrer-Policy | strict-origin-when-cross-origin | Full URLs, including tokens in query strings, leaking to other sites | Reports that rely on full referring paths from other origins | Medium, low risk |
| Permissions-Policy | camera=(), microphone=(), geolocation=() | Injected or embedded code using powerful browser features | Features your pages or embeds need, such as video calls | Medium; only Chromium-based browsers apply it |
| Cross-Origin-Opener-Policy | same-origin, or same-origin-allow-popups with OAuth or payment popups | Cross-site windows keeping a handle on your page; side-channel attacks | Login and payment popups that talk back to their opener | Optional |
| Cross-Origin-Resource-Policy | same-site on private resources | Other sites embedding your responses | Assets you want other sites to embed | Optional |
| Cross-Origin-Embedder-Policy | require-corp, only if you need cross-origin isolation | Makes SharedArrayBuffer and precise timers safe to enable | Every third-party resource without CORP or CORS | Only when needed |
| X-XSS-Protection | 0, or do not send it | Nothing in current browsers | The old filters could create vulnerabilities | Remove |
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.
# /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.
# 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)'
done06What 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 change | Header responsible | How to confirm | Fix |
|---|---|---|---|
| A subdomain shows a certificate warning that cannot be bypassed | Strict-Transport-Security with includeSubDomains | The warning names HSTS; see your connection is not private | Give the subdomain a valid certificate |
| Buttons do nothing or analytics stop | Content-Security-Policy | The console lists each blocked source | Add a nonce or hash, or allow the exact host; never add 'unsafe-inline' to silence it |
| A stylesheet or script stops loading | X-Content-Type-Options | The Network panel shows it served as text/plain or similar | Serve .js as text/javascript and .css as text/css |
| A partner embed shows an empty frame | frame-ancestors or X-Frame-Options | The response carries DENY, SAMEORIGIN or two values | List the partner in frame-ancestors; send X-Frame-Options once |
| External sign-in hangs after the popup | Cross-Origin-Opener-Policy: same-origin | The popup cannot reach its opener | Use same-origin-allow-popups on the opening page |
| Third-party images or fonts stop loading | Cross-Origin-Embedder-Policy: require-corp | The console reports a COEP block | Load them in CORS mode, or drop COEP |
| Headers missing on errors or assets | nginx add_header inheritance, or no always | The curl count loop above | Add 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.
Questions
Frequently asked questions
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.
- 01HTTP Security Response Headers Cheat Sheet · OWASP
- 02Content Security Policy Cheat Sheet (Report-Only, strict policy, meta tag limits) · OWASP
- 03RFC 6797, HTTP Strict Transport Security · RFC Editor
- 04HSTS preload list: submission requirements and deployment recommendations · hstspreload.org
- 05Module ngx_http_headers_module (add_header, always, add_header_inherit) · nginx.org
- 06HTML Standard: the X-Frame-Options header · WHATWG
- 07Fetch Standard: X-Content-Type-Options header · WHATWG
- 08Permissions-Policy header and browser compatibility · MDN
- 09Referrer-Policy header and browser defaults · MDN
- 10Cross-Origin-Opener-Policy · MDN
- 11Content-Security-Policy: multiple policies · MDN
- 12X-XSS-Protection · MDN
Last updated