(KEEPSITESAFE)

Guide

HTTP security headers, and the exact config

What each header actually stops, which widely-repeated advice is now wrong, and the two nginx mistakes that silently send nothing at all.

By KeepSiteSafe ·

The cheapest security work available, and the easiest to get wrong

Security headers are configuration, not code. They do not touch your application, they apply in minutes, and they shut down whole categories of attack. Nothing else in web security has that ratio.

The problem is that most guides still list headers deprecated years ago, and some recommend settings that now make things worse. This one covers only what matters in 2026, says what actually happens when each header is missing, and gives complete configuration — including the two mistakes that silently stop nginx from sending anything at all.

HSTS — closing the first-request window

Strict-Transport-Security tells the browser never to attempt HTTP for your domain again. It exists to close one specific gap: the very first request. When someone types yoursite.com with no protocol, the browser tries HTTP, and whoever controls the network — a café access point, a hostile ISP — can intercept that request before your redirect to HTTPS ever arrives. With HSTS remembered, the plaintext request is never sent.

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

includeSubDomains is the part that catches people. It applies to every subdomain, including internal ones you forgot about. If legacy.yoursite.com is still HTTP-only, or has a self-signed certificate, it becomes unreachable in any browser that has visited your main domain. Enumerate your subdomains before you add it.

preload goes further: it submits your domain to a list compiled into the browsers themselves. Removal takes months and cannot be rushed. Add it only when you are confident every subdomain is stably on HTTPS, and understand that you are making a decision that is hard to walk back.

One more thing worth knowing: HSTS only takes effect after a successful HTTPS response. It protects returning visitors, not first-time ones — that is exactly what preload fixes, and the only reason it exists.

Content-Security-Policy — the one that still works after an XSS

CSP declares which origins the browser may load scripts, styles, images and frames from. It is the only defence that still helps once an XSS has succeeded: the attacker injects the script, and the browser refuses to run it.

The near-universal mistake is adding 'unsafe-inline' to script-src because the site broke without it. That does not weaken the policy, it removes it. Permitting inline script is precisely what an XSS payload needs. A CSP containing 'unsafe-inline' in script-src passes automated header checks and stops nothing.

The correct fix is a nonce: a random value, different on every response, present on every legitimate script tag.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_PER_REQUEST}';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none'

'unsafe-inline' on style-src is a far more defensible compromise. Styles do not execute, and removing it breaks essentially every CSS framework. The distinction is not inconsistency — it is the difference between code and presentation.

Do not overlook base-uri and form-action. Without base-uri 'self', an injected <base> tag silently re-points every relative URL on the page, which turns a minor injection into full script control. Without form-action 'self', an injected form can post your users' credentials to another origin. Both are one line each and both are commonly missing.

frame-ancestors, and why X-Frame-Options is no longer the answer

Clickjacking works by loading your site in an invisible iframe over a decoy page, so the user believes they are clicking one thing and actually click another. For years the defence was X-Frame-Options: DENY.

CSP's frame-ancestors now supersedes it. It is more precise — it accepts a list of permitted origins rather than all-or-nothing — and when both headers are present, modern browsers treat frame-ancestors as authoritative and ignore X-Frame-Options entirely. Keep X-Frame-Options for old clients; it is redundant rather than harmful. But if the two disagree, understand which one is actually in force.

The rest of the useful set

X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Cross-Origin-Opener-Policy: same-origin
  • nosniff stops the browser guessing a file's type in defiance of its Content-Type. Without it, a user-uploaded image can be interpreted and executed as JavaScript.
  • Referrer-Policy stops the full URL — often containing tokens or record ids in the query string — being sent to external sites in the Referer header.
  • Permissions-Policy switches off browser APIs you do not use. If you never need the camera, denying it means an injected script cannot ask for it either. Note that the interest-cohort value some guides still list is obsolete: FLoC was cancelled, and the token does nothing.
  • Cross-Origin-Opener-Policy severs the window reference between your page and whatever opened it, closing a family of cross-origin attacks that rely on holding a handle to your window.

What to remove

X-XSS-Protection should be deleted, not set. The browser XSS auditor it controlled was removed from Chrome and Edge because it introduced vulnerabilities of its own, and on some older browsers a badly chosen value is worse than no header. Guides that still recommend it are simply out of date.

Remove anything that advertises your stack: X-Powered-By, a Server header carrying a version number, framework-specific headers. This is not a vulnerability by itself. It is a shortlist, handed to anyone deciding which exploit to try first.

The configuration

nginx

# in the server block, or http for site-wide
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
server_tokens off;

Two traps here, and both fail silently.

First, without always, nginx omits the header on error responses — so your 404 and 500 pages ship unprotected. Those are pages an attacker can often reach deliberately.

Second, and worse: add_header does not merge across levels. If you add a single add_header inside a location block, every add_header inherited from the enclosing server block is discarded for that location. One extra header in one location can quietly strip all six from a route, and nothing warns you. Recent nginx offers add_header_inherit to change this, but do not rely on it being available — check curl -I against each distinct route type instead.

Apache

Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Cross-Origin-Opener-Policy "same-origin"
Header unset X-Powered-By
ServerTokens Prod

Requires mod_headers. Use always rather than onsuccess for the same reason as nginx — onsuccess is the default and skips error responses.

Next.js

// next.config.js
async headers() {
  return [{
    source: '/:path*',
    headers: [
      { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
      { key: 'X-Content-Type-Options', value: 'nosniff' },
      { key: 'X-Frame-Options', value: 'DENY' },
      { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
      { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
    ],
  }];
}

A nonce-based CSP cannot go here. next.config.js is evaluated once at build time and the nonce has to change on every request, so it belongs in middleware where you can generate one per response and pass it to your script tags.

Do not set CSP in both places. When a browser receives two Content-Security-Policy headers it enforces the intersection of both, which is almost always stricter than either author intended and produces breakage that is genuinely hard to diagnose — the policy being violated does not appear in full in either configuration file.

Verify from outside

Check the response the world actually receives, not the file you edited. A CDN, a reverse proxy, or a load balancer can add, strip or overwrite anything your origin sets, and a location block can discard the lot.

curl -sI https://yoursite.com | grep -i 'strict-transport\|content-security\|x-content-type\|x-frame\|referrer\|permissions\|cross-origin'

Test more than the homepage. Test an API route, a static asset, and a deliberate 404 — those are the three places headers most often go missing, and all three are reachable by anyone.

Or run a free scan: the header grade comes from Mozilla's HTTP Observatory, the same engine behind Mozilla's own validator, so the number is verifiable against a third party rather than being our opinion. The scan also covers TLS, DNS and email records and cookie flags, which a header grade alone never looks at.

Every check described here is one KeepSiteSafe actually runs, using named open-source engines — Mozilla’s HTTP Observatory, CryptoLyzer and the CISA KEV catalogue. You can verify any finding against its source rather than taking our word for it.

Check your own headers

Free scan, no signup, any domain — graded by Mozilla HTTP Observatory, with the exact line to add for every finding.

Scan free
HTTP security headers: what each one stops, and the exact config | KeepSiteSafe