dotvitals

HTTP security headers explained

Updated ·19 min read

HTTP security headers are instructions a server sends alongside a page telling the browser to be stricter than it would be by default: do not run inline scripts, never come back over plain HTTP, do not let another site put this page in a frame, do not guess what type this file is. They cost nothing to send and they close off whole categories of attack without changing a line of the page itself.

They also accumulate folklore. Several headers that appear near the top of every checklist do nothing at all now, one of them was removed from browsers because it was itself exploitable, and two of the useful ones — HSTS and CSP — can take a site down if they are deployed without understanding what they commit you to. A page scoring well on a headers checker is not the same as a page that is well configured.

This guide goes through each header that is still worth sending, what it actually does, how to roll out the two risky ones safely, which headers are obsolete and should simply be deleted, and how to write the configuration for nginx, Apache and Cloudflare. Standards status was checked against MDN and the relevant specification in September 2026, and the specifications involved here move faster than most.

Check yours now

What these headers do, and what they do not

Every header here is a defence-in-depth measure. None of them fixes a vulnerability; they limit what a vulnerability can do. A Content Security Policy does not stop you having a cross-site scripting bug — it is the difference between that bug being a defect and being a breach. HSTS does not encrypt anything — TLS does that — it removes the unencrypted request that happens before TLS gets involved.

That framing matters because it sets the priority. Sending every header on this page while running an unpatched application is worse than useless, because it produces a green grade that stops anybody looking. The headers are cheap, which is the argument for sending them; it is not an argument for treating them as the security work.

The other thing to understand up front is that a header only counts if it is on the live response. A header set in a configuration file that a proxy later strips, or set in a block that never runs for the URL in question, provides exactly nothing — and this is common enough that verifying against the live response rather than the configuration is the whole reason our security headers checker reads what the server actually returned.

Content-Security-Policy: the one that does the most and costs the most

CSP tells the browser which sources a page is allowed to load each kind of resource from, and — much more importantly — whether it may execute script that was written directly into the HTML. It is specified in Content Security Policy Level 3, which as of this writing is a W3C Working Draft dated 16 September 2026; it is republished frequently, so cite the dated version if you need a fixed reference.

The mechanism is a list of directives, each naming a resource type and the sources permitted for it. default-src acts as the fallback for the fetch directives, so default-src 'self' covers scripts, styles, images, fonts and the rest in one go unless a more specific directive overrides it. Two important directives do not fall back to default-src, and both are routinely forgotten as a result: base-uri and frame-ancestors. Setting default-src 'none' does not restrict either one.

base-uri is the one that quietly undoes everything else. Without it, an attacker who can inject a single base tag into the page changes what every relative script URL on that page resolves to, pointing them all at their own server — while your policy, which only allows scripts from your own origin, is satisfied, because the browser now believes their server is your origin's base. object-src 'none' is the other cheap one: it disables plugin content, which no current browser supports anyway, so it breaks nothing.

The rule to internalise is that a policy is only as strong as its weakest directive. Our scanner grades what a policy permits rather than whether the header exists, because default-src * parses perfectly, is present on the response, and restricts nothing at all.

A realistic starting policy, and the report-only rollout

Do not deploy an enforcing CSP straight onto a site you have not measured. Deploy it as Content-Security-Policy-Report-Only first: the browser evaluates the policy, sends a violation report for everything it would have blocked, and loads the resource anyway. Collect reports for a week — long enough to cover whatever runs weekly — fix the legitimate sources they reveal, and only then send the same value under the enforcing header name.

Be clear-eyed about what report-only is, though. It is the correct way to develop a policy and it is not protection. Sites routinely sit in report-only mode for years, showing a long and impressive policy in their headers while enforcing nothing, which is why our scanner treats a report-only policy the same as no policy: it blocks nothing, however strict it looks.

Both headers can be sent at the same time, and this is the technique worth knowing. Send a policy you are confident in under Content-Security-Policy, and a stricter candidate under Content-Security-Policy-Report-Only. You keep the protection you have while measuring the next step, and you can tighten indefinitely without ever risking the live site.

The starting policy below is deliberately conservative about scripts and permissive about images, because that is the shape most sites actually need. Adjust it to what your own reports show rather than to what looks strict.

A starting policy: deploy the report-only form first
# Step 1 — measure. Nothing is blocked; violations are reported.
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; report-uri /csp-report

# Step 2 — after a week of clean reports, send the same value enforcing.
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'

# Step 3 — keep tightening safely: enforce what works, measure the next step.
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'nonce-R4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; report-uri /csp-report

Nonces, hashes, and why unsafe-inline defeats the policy

Cross-site scripting works by getting script into the page's HTML. A policy containing 'unsafe-inline' in script-src permits exactly that, which switches off the protection against the most common attack while leaving a policy in the headers that looks like protection. The CSP specification says as much directly: developers should not include either 'unsafe-inline' or data: as valid sources, because both allow code to be included directly in the document.

The two supported ways to allow your own inline script without allowing everyone's are nonces and hashes. A nonce is a random value generated fresh for every single response, placed both in the header and on each legitimate script tag; the browser runs a script only if its nonce attribute matches. A hash is the base64 SHA-256 of the script's exact contents, listed in the policy; the browser runs the script only if it hashes to a listed value. Nonces suit dynamically generated pages, hashes suit a fixed set of inline blocks that rarely change.

A nonce must be unpredictable and must be different on every response. A nonce reused across responses, or derived from something an attacker can see, is no protection at all — an injected script can simply carry the same nonce. This is the single most common way a nonce-based policy is deployed incorrectly.

Here is the part that surprises people, and it is worth getting right because it changes what you should write. Under CSP Level 3, if a source list contains a nonce or a hash, the browser ignores 'unsafe-inline' entirely. The specification's own examples list 'unsafe-inline' 'nonce-abc' among the source lists that do not allow all inline behaviour. That means keeping 'unsafe-inline' alongside a nonce is a deliberate, harmless backwards-compatibility shim for browsers too old to understand nonces — not a mistake. Our scanner reflects this: it reports 'unsafe-inline' only when there is no nonce or hash to supersede it, because only then is it actually in force.

'strict-dynamic' is the directive that makes nonce-based policies maintainable. The specification says that when it is present in script-src or default-src, host and scheme sources and the 'unsafe-inline' and 'self' keywords are ignored for script loading, nonces and hashes are honoured, and scripts loaded by a trusted script are themselves trusted. In practice it lets you stop maintaining an allow-list of script hosts, which matters because host allow-lists are bypassable through any JSONP endpoint or hosted library that happens to be on them. The specification's own caveat is worth heeding: audit where your trusted scripts load further scripts from, and make sure those calls are not driven by untrusted data.

One honest note the specification itself makes and most guides omit: using a nonce to allow inline script is less secure than not needing one, because the nonce overrides the restrictions in the directive it sits in. The best policy is one with no inline script to allow. A nonce is the second-best answer, not the goal.

HSTS: real protection, and a real commitment

Strict-Transport-Security tells a browser to use HTTPS for this hostname and nothing else, for a stated number of seconds. Once a browser has the policy, it rewrites any http:// URL for that host to https:// internally, before anything goes on the wire — so the plaintext first request that a redirect can only respond to after the fact never happens.

Two rules from RFC 6797 govern whether the header does anything at all. It must carry a max-age directive; a header without one is ignored, and max-age=0 is the documented way to withdraw the policy rather than a way to set it weakly. And browsers must ignore the header entirely when it arrives over plain HTTP, so it has to be sent on the HTTPS response. Our scanner fails this check for a header that is present but inert, not only for one that is absent.

This is the header that can genuinely take things down, so deploy it in stages. Confirm every hostname the policy will cover already serves valid HTTPS. Set max-age=300 and confirm nothing breaks. Raise it to 31536000, a year, once you are confident. Add includeSubDomains only after auditing every subdomain, because after that a browser will refuse to load any subdomain that does not serve a valid certificate — including internal tools and old staging names that are still in DNS and that nobody has thought about in two years.

Understand what rolling back costs before you raise the value. Lowering max-age does not retroactively shorten the policy a browser already stored; the browser keeps the old duration until it next sees a response from you, and a browser that cannot reach you over HTTPS is a browser that will not see anything from you. If the certificate on a subdomain lapses after includeSubDomains is in force, that subdomain is unreachable for the remainder of the stored max-age, with no clickable warning to bypass and nothing you can do from your side except fix the certificate. That is the argument for a short max-age during rollout and a long one only after.

To withdraw HSTS deliberately, serve Strict-Transport-Security: max-age=0 over HTTPS and leave it there long enough for every visitor to have made at least one visit. It takes effect for a given browser the next time that browser successfully reaches you over HTTPS, and not before.

Our scanner warns when max-age is below six months. That is its own floor, and it is a warning rather than a failure because a short value is the correct way to start a rollout. It is not a finished configuration.

Preloading: read this before you submit

HSTS only protects a browser that has already seen the header, which leaves the very first visit a browser ever makes to your site unprotected. Preloading closes that gap by building the policy into the browser itself: hostnames on the list are shipped inside the browser binary, so even a brand-new browser refuses plaintext for them.

The requirements, quoted from hstspreload.org's own submission form as of September 2026: serve a valid certificate; redirect from HTTP to HTTPS on the same host if you listen on port 80; serve all subdomains over HTTPS, including the www subdomain if a DNS record for it exists; and serve an HSTS header on the base domain over HTTPS with max-age of at least 31536000 seconds (one year), includeSubDomains, and the preload directive. If your HTTPS site itself redirects, that redirect must carry the header, not just the page it lands on. The site notes explicitly that preloading applies to all subdomains including internal ones that are not publicly accessible.

Now the part that should decide whether you do this at all. hstspreload.org says, in its own words, that inclusion in the preload list cannot easily be undone; that domains can be removed but it takes months for a change to reach users with a Chrome update, with no guarantees about other browsers; and that you should not request inclusion unless you are sure you can support HTTPS for your entire site and all its subdomains in the long term. Its removal page is more specific: a removal may take 6 to 12 weeks to reach most Chrome users, and may take longer for other browsers. Addition is similar in the other direction — new entries are hardcoded into the Chrome source and can take several months to reach the stable version.

So the real cost of preloading is this: if you later need to serve any hostname under your domain over plain HTTP — an acquired subdomain running someone else's legacy system, a device management interface, a partner's endpoint — you cannot. Not quickly. You submit a removal request and then wait six to twelve weeks for Chrome alone, during which that hostname is simply unreachable for everyone. There is no emergency switch.

It is worth knowing that hstspreload.org itself does not recommend preloading. Its own words: the benefits provided by HSTS preloading are minimal compared to the benefits provided by HSTS, and while HSTS is recommended, HSTS preloading is not. It also asks that tools which offer to enable HSTS should not include the preload directive by default. Our scanner treats preloading as an informational note worth zero points for the same reason: it is a deliberate decision with a long undo, not a setting to tick.

To request removal, first send a valid HSTS header without the preload directive — that alone is what signals the request — then submit the removal form. If you want to disable HSTS entirely rather than just leave the list, max-age=0 is the knockout value.

Stopping your page being put in a frame

Clickjacking is an attacker loading your real page, with the visitor's real session, inside an invisible frame under their own overlay. The visitor thinks they are clicking a video play button; they are approving a transfer on your site. Two headers address it, and the relationship between them is frequently described wrongly.

CSP's frame-ancestors directive is the modern control: frame-ancestors 'none' refuses all framing, frame-ancestors 'self' allows only your own origin, and frame-ancestors 'self' https://partner.example allows a named embedder. Two notes from the specification. It does not fall back to default-src — the spec says so explicitly, and gives the example that a policy declaring default-src 'none' will still allow the resource to be embedded by anyone. And it is ignored when the policy is delivered through a meta element, so this one must be an HTTP header.

X-Frame-Options is the older header. It is not deprecated — it is standardised in the WHATWG HTML specification — but it is superseded in a specific way: both the CSP specification and the HTML specification state that if an enforcing policy contains frame-ancestors, X-Frame-Options is ignored entirely. So frame-ancestors alone is complete protection for any browser that supports it, and X-Frame-Options is worth sending only as a fallback for anything that does not.

Its valid values are exactly DENY and SAMEORIGIN. ALLOW-FROM is obsolete and, per the HTML specification, an invalid value results in embedding being allowed — so X-Frame-Options: ALLOW-FROM https://partner.example is not weak protection, it is no protection, and it reads on a checklist as though the question has been answered. frame-ancestors is its replacement, and the only one.

A report-only CSP does not count here either, since it enforces nothing. If frame-ancestors is your only framing control, it has to be in the enforcing header.

X-Content-Type-Options, Referrer-Policy and Permissions-Policy

X-Content-Type-Options: nosniff is the cheapest header on this page and there is no reason not to send it. It is standardised in the WHATWG Fetch specification and it does two distinct things: for requests whose destination is a script or a stylesheet, the browser blocks the response if its declared type is not a JavaScript or CSS type; for everything else, including navigations, the browser uses the declared Content-Type as given instead of sniffing the bytes. That closes the classic case of a file uploaded as an image, containing HTML, being served back and executed as a page on your own origin.

The only value browsers act on is the single token nosniff. Anything else is discarded — including nosniff inside a comma-separated list, which is what a header sent twice collapses into, so a duplicate can silently disable it. Confirm your Content-Type headers are accurate before enabling it, because nosniff makes the browser trust them absolutely.

Referrer-Policy controls how much of your URLs is sent to sites a visitor clicks through to. Every current browser already defaults to strict-origin-when-cross-origin, which sends only the origin across origins and nothing at all on a downgrade to HTTP, so setting it explicitly pins that behaviour rather than transforming it. The reason to set it anyway is that several values still in circulation are much worse: unsafe-url sends the complete URL everywhere including over plain HTTP, and no-referrer-when-downgrade, the old default, still sends the full URL to any HTTPS destination. If your URLs carry password reset tokens, invitation links, search queries or account identifiers, use no-referrer on those pages specifically — and consider getting those values out of the URL.

Permissions-Policy controls which browser features the page and anything embedded in it may use: camera, microphone, geolocation and so on. The syntax is feature=(allowlist), for example camera=(), microphone=(), geolocation=(self) — an empty list denies the feature to everyone including your own page. It matters most when the page embeds third-party frames, since without it an embedded advert or widget can request features in your site's name.

Two caveats on Permissions-Policy, stated plainly because most guides present it as settled. MDN still marks it as experimental with limited availability, and the specification is a W3C Working Draft dated 18 June 2026 rather than a Recommendation. And it does not accept the older Feature-Policy syntax; a value written in that form is ignored rather than rejected, which is exactly the failure mode that leaves you believing a restriction is in place. Our scanner records its absence as informational and deducts nothing, which is the right weight for a header in that state.

The headers that are obsolete

These appear on checklists and in copied configuration blocks. None of them should be sent, and one of them is actively worse than nothing. Delete them rather than setting them to a safe value — with the single exception noted below.

  • X-XSS-Protection. MDN marks it both deprecated and non-standard, and recommends Content-Security-Policy instead. It enabled a browser XSS filter that every major engine has removed: Edge dropped it, Chrome removed the auditor in version 78, and Firefox never implemented it. It was removed because it was a vulnerability in its own right — MDN notes that in some cases it can create XSS vulnerabilities in otherwise safe websites. The exception: X-XSS-Protection: 0 explicitly disables the filter on any browser that still has one, and is what to send if a compliance checklist insists the header be present. Our scanner does not report that value; it reports 1 and 1; mode=block.
  • Expect-CT. MDN marks it deprecated and says it is mostly obsolete since June 2021. Only Chromium ever implemented it, and Chromium deprecated it as of version 107 because Certificate Transparency is now enforced by default. There is no replacement header to send. Delete it.
  • Feature-Policy. Renamed to Permissions-Policy, with different header syntax. MDN no longer has a page for it — the URL redirects to Permissions-Policy. Sending the old header achieves nothing; port the value to the new syntax and delete the old one.
  • X-UA-Compatible. Told Internet Explorer which rendering engine to emulate. IE has been out of support since June 2022 and nothing current reads it.
  • P3P. A compact privacy policy for an abandoned W3C specification, only ever consumed by old Internet Explorer.
  • Pragma: no-cache and Expires: 0 used as caching instructions. RFC 9111 deprecates Pragma outright and describes it as a request header; Expires: 0 is an invalid date that happens to work. Replace both with an explicit Cache-Control.

The reason to delete rather than to leave them is not byte count. It is that a dead header reads like a live one. X-XSS-Protection in particular gets ticked off a checklist as protection against cross-site scripting while providing none, which is the exact shape of a false reassurance.

Configuration for nginx, Apache and Cloudflare

The snippets below are a dated snapshot, checked against each vendor's own documentation in September 2026. Cloudflare's dashboard navigation in particular has changed, so verify the path rather than trusting an older screenshot.

In nginx, the trap is inheritance. The documentation states that add_header directives are inherited from the previous configuration level if and only if there are no add_header directives defined at the current level — so a single add_header inside a location block silently discards every security header set in the enclosing server block. Historically the only fix was to repeat every header in every block that sets any. nginx 1.29.3 added add_header_inherit, whose merge value appends the parent level's values to the current level's; if you are on 1.29.3 or later, add_header_inherit merge; at the top level is the cleaner answer. Check nginx -v before relying on it. Separately, the always flag matters on every add_header: without it the header is omitted on error responses, which are exactly the ones an attacker aims for.

In Apache, use Header always set and put it in the virtual host rather than .htaccess — .htaccess is re-read on every request, is disabled outright by AllowOverride None on many hardened installations, and runs too late to influence a response the virtual host generates itself. One subtlety from Apache's own documentation: always is not a superset of onsuccess with respect to existing headers, so in some setups — notably modifying a header generated by a CGI script or by mod_proxy_fcgi — you genuinely need the directive twice, once with each condition.

On Cloudflare, as of September 2026 the documented path is the Rules Overview page, then Create rule, then Response Header Transform Rule. Transform Rules are available on the Free plan with a quota of 10 active rules per zone; regular expressions in rule expressions are Business and Enterprise only. Remember that this is an edge control: an origin reachable directly, by IP or through a DNS record that is not proxied, still serves the response without your header, and that origin is what a scanner connecting to it measures. Setting the headers at the origin covers both paths.

Whichever you use, verify against the live response afterwards rather than against the configuration. The checks that matter: every header you intended is present, each appears exactly once, and the CSP is on Content-Security-Policy rather than only on the report-only header. Two different CSP headers do not combine into a stronger one — browsers enforce both, which means the intersection, and that is almost never what either author intended.

The same header set for nginx, Apache and curl verification
# nginx — in the server block. Repeat in any location that sets its own add_header,
# or on nginx 1.29.3+ set "add_header_inherit merge;" once at the http level.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header X-Frame-Options "DENY" always;

# Apache — in the virtual host, not .htaccess
# <IfModule mod_headers.c>
#   Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
#   Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
#   Header always set X-Content-Type-Options "nosniff"
#   Header always set Referrer-Policy "strict-origin-when-cross-origin"
#   Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
#   Header always set X-Frame-Options "DENY"
# </IfModule>

# Verify against the live response, not the config file
# curl -sSI https://example.com/ | grep -iE 'strict-transport|content-security|x-content-type|referrer-policy|permissions-policy|x-frame'
# curl -sSI https://example.com/ | grep -ci '^content-security-policy:'   # expect exactly 1
# curl -sSI https://example.com/nonexistent | grep -ci 'x-content-type'   # headers must survive a 404

What commonly goes wrong

  • A CSP that sits in report-only mode indefinitely. It reads as a policy in every headers listing and blocks nothing, so the site has the same protection as one with no policy at all while appearing configured.
  • 'unsafe-inline' in script-src with no nonce or hash alongside it, or a nonce that is not regenerated on every response. The first permits precisely the thing cross-site scripting does; the second lets an injected script carry the same nonce. Either way the policy is decorative.
  • A policy with default-src 'none' and no base-uri or frame-ancestors. Neither falls back to default-src, so the page is still framable by anyone and still vulnerable to an injected base tag redirecting every relative script URL.
  • X-Frame-Options: ALLOW-FROM. It is not weak protection. Per the HTML specification an invalid value results in embedding being allowed, so it is no protection at all while looking like an answer. Use frame-ancestors.
  • Adding an add_header inside an nginx location block without realising it discards every header inherited from the server block. The headers vanish for exactly the URLs that location serves, and the configuration still looks correct.
  • Enabling HSTS includeSubDomains without auditing subdomains, or submitting to the preload list before being certain about every subdomain forever. A forgotten staging hostname without a valid certificate becomes unreachable with no bypass, and preload removal takes six to twelve weeks to reach most Chrome users and longer elsewhere.
  • Setting the headers only at a CDN, or sending one twice with different values. An origin reachable directly serves the response without them; and two Content-Security-Policy headers are both enforced, giving their intersection, which is almost never what either author wrote.

Check your domain with the security headers checker