Which security headers actually matter? What each one defends, the settings that do nothing, and what an outside check can conclude

DedSec Security Research ·

Consider a hypothetical: the header report for the main site is clean, with Content-Security-Policy present, X-Frame-Options: DENY, Strict-Transport-Security with a year of max-age, nosniff, a Referrer-Policy. Then a penetration tester produces a working clickjacking proof of concept against the account-deletion page on that same host.

Both are right. The report fetched /. The tester framed /account/delete. In the web server configuration, the block serving /account/ had gained its own add_header line for Cache-Control. In nginx that is enough to drop every header set at the server level for that path, because add_header directives "are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level", according to the nginx documentation. Nothing about the response for / changed.

This post covers what each common security response header actually defends against, the configurations that look present and do nothing, and the limits of any check run from outside, whoever runs it. Quotations come from the specifications and vendor documentation named alongside them.

What a response header can and cannot do

A security response header is an instruction to a browser. It does nothing on the server, nothing for a client that ignores it, and nothing for a request made by curl, a mobile app's HTTP library or an attacker's script. Each header switches on or restricts a browser behaviour, and its value depends on whether that behaviour is part of an attack against your users.

That gives two questions for every header finding, and they are independent:

None of these headers fixes a vulnerability. CSP is the clearest case. Google's guidance on web.dev is explicit: "CSP is a defense-in-depth technique that can prevent the execution of malicious scripts, but it's not a substitute for avoiding and promptly fixing XSS bugs." A CSP only matters once someone has found an injection. If there is no injection it does nothing, and an injectable page with a strong CSP still has the injection.

Content-Security-Policy

What it defends against

CSP tells the browser which scripts, styles, frames, plugins and other resources a document may load and execute. Its main security job is limiting what an attacker can do after injecting markup into your page: if the injected script tag is not permitted by the policy, it does not run.

Why allowlist policies are bypassable

The original model was an allowlist: script-src 'self' cdn.example.com analytics.example.net. The browser runs scripts from those hosts and nothing else. The assumption, as Weichselbaum, Spagnuolo, Lekies and Janc put it in their CCS 2016 paper "CSP Is Dead, Long Live CSP! On the Insecurity of Whitelists and the Future of Content Security Policy", is "that domains whitelisted in the policy only serve safe content". The paper shows why that assumption fails. An attacker who can inject markup does not need to inject a script body. They inject a script tag whose src points at something already permitted that will run code of their choosing:

The point that matters for defenders: none of these endpoints is a vulnerability on its own, so nobody has a reason to remove them. The paper notes they "become a security problem because they allow a policy to be bypassed", on your origin and on every third-party host and CDN in your allowlist, whose owners "might not be aware of CSP". Your policy's strength depends on the content of hosts you do not control.

The paper measured this across a search-engine corpus in 2016: of 26,011 unique policies, it found bypasses in 94.72%, and 14 of the 15 domains most commonly allowlisted for scripts contained unsafe endpoints. Those figures describe the web of 2016, not today's web, but the mechanism has not changed: an allowlist is only as safe as everything served from every host on it.

The simpler failures

The same paper lists more basic mistakes, and the CSP Level 3 specification warns against them directly:

The strict model: nonces or hashes, plus 'strict-dynamic'

The alternative the paper proposed, now Google's recommendation on web.dev, does not list hosts at all. Each legitimate script carries a per-response random nonce attribute or is identified by its hash, and the policy permits only those:

The hash-based variant replaces the nonce with 'sha256-...' entries for each inline script, and web.dev suggests it for statically served or cached pages where a fresh nonce per response is impractical.

'strict-dynamic' lets a trusted script load its own dependencies without listing them. CSP Level 3 describes its two effects: host-source and scheme-source expressions, and 'unsafe-inline' and 'self', "will be ignored when loading script", and scripts inserted by a trusted script that are not parser-inserted are allowed. The policy is no longer a list of hosts that someone has to keep safe. An injected script tag has no valid nonce and does not run.

Two requirements make this work, and both can be checked:

web.dev also lists what a strict policy does not stop: injection directly into the body or src of a nonced script, injection into the URL of a script created with document.createElement('script'), including through library functions such as jQuery's .html(), template injection in old AngularJS applications, and anything reachable through eval() if the policy allows 'unsafe-eval'.

The fallback that looks like a weakness

web.dev's suggested policy for compatibility with very old browsers (it says 4+ years) is:

That contains both 'unsafe-inline' and https:, which look like the two worst things a policy can contain. They are deliberate fallbacks. The specification's algorithm for whether a source list allows all inline behaviour returns "Does Not Allow" as soon as it sees a nonce or hash, before it considers 'unsafe-inline'. web.dev says: "All recent browsers ignore unsafe-inline if a CSP nonce or hash is present," and "https: and unsafe-inline don't make your policy less safe. Any browser that supports strict-dynamic knows to ignore them."

A checker that flags any policy containing the string 'unsafe-inline' raises a false positive on the recommended configuration. It is the same mistake as a static analyser that matches a sink as a bare substring: it reads a token without the context that decides what the token means. The post on SAST false positives covers that family of mistakes. A policy evaluator has to apply the browser's precedence rules, not search the policy for strings.

Report-only means enforcement is off

Content-Security-Policy-Report-Only exists to try a policy before enforcing it. In the specification's words, the user agent will "monitor" each policy it contains, and web.dev says that in report-only mode "your CSP doesn't affect your app's behavior". An injected script runs, and the browser sends a report if a reporting endpoint is configured. MDN adds that the report-to directive "should be used with this header or it will have no effect."

So a Report-Only header with no Content-Security-Policy header gives no protection. A Report-Only header with no reporting directive does not send reports. Report-only is how teams are advised to start, and nothing forces the move to enforcement. An outside check should report "CSP in report-only mode" as its own state, not as "CSP present".

A policy in a meta tag

A document can carry a policy in a meta element with http-equiv="Content-Security-Policy". The specification limits what that can do: "The Content-Security-Policy-Report-Only header is not supported inside a meta element. Neither are the report-uri, frame-ancestors, and sandbox directives." It also warns that "policies in meta elements are not applied to content which precedes them", including resources fetched through a Link response header or link and script elements earlier in the document.

This cuts both ways for an external check. A check that reads only response headers reports "no CSP" for a page whose meta policy is enforced. And a meta policy with frame-ancestors 'none' looks like clickjacking protection to someone reading the HTML, but the browser ignores that directive in a meta element.

Multiple policies intersect

A response can carry more than one policy: two headers, one header with comma-separated policies, or a header plus a meta element. All are enforced. The specification's worked example concludes that "adding additional policies to the list of policies to enforce can only further restrict the capabilities of the protected resource", and a resource loads only if every policy permits it.

Two consequences follow. A permissive policy added by a CDN or framework does not weaken a strict one sent by the application; the strict one still applies. And an evaluator that reads only the first policy, or merges them as a union, will misjudge what the browser enforces. Multiple policies can also break pages, because a resource one policy permits is blocked by another, and the tempting response is to loosen whichever policy is easiest to edit.

Clickjacking: X-Frame-Options and frame-ancestors

Clickjacking puts your page in an invisible frame on an attacker's page and gets a user to click something on it, such as a confirm button or a permission grant. Defending against it means telling the browser who may frame the page. There are two mechanisms, and their interaction is where configurations quietly fail.

X-Frame-Options

The HTML Standard, which now holds the processing model and "supersedes" the original RFC 7034, defines two valid values: DENY and SAMEORIGIN. Everything else is treated as if the header were absent:

frame-ancestors, and which one wins

CSP's frame-ancestors directive does the same job with an allowlist of origins: the CSP specification calls frame-ancestors 'none' "roughly equivalent" to DENY and 'self' to SAMEORIGIN, and it can name specific partner origins, which X-Frame-Options cannot.

When both are present, frame-ancestors wins. The HTML Standard's check returns early, before reading X-Frame-Options at all, if any policy whose disposition is "enforce" contains a frame-ancestors directive. CSP Level 3 says the directive "overrides the X-Frame-Options header" for backwards-compatible deployment.

That precedence creates a configuration that looks safe and is not. X-Frame-Options: DENY together with a CSP containing frame-ancestors https: allows framing by any HTTPS site, and frame-ancestors * by essentially any site, plain HTTP included, because the browser never reads the DENY. Someone checking for X-Frame-Options sees DENY and moves on.

Three further details:

The practical rule: send frame-ancestors in an enforced header policy with the narrowest list that works, keep X-Frame-Options: DENY or SAMEORIGIN alongside it for older clients if you like, and never let the two disagree.

Strict-Transport-Security

HSTS tells a browser to use only HTTPS for a host for max-age seconds. MDN summarises the effect: the browser rewrites http URLs to the host as https before sending anything, and "the browser will not allow the user to bypass secure connection errors, such as an invalid certificate." hstspreload.org lists what that protects against on hostile networks: browsing-history leaks, protocol downgrades by rewriting the HTTP-to-HTTPS redirect, and cookie hijacking or injection over plain HTTP. The TLS posture post covers HSTS alongside certificate drift. These are the parts a header check sees.

What is ignored, and what is remembered

The first visit, and preloading

Before a browser has seen the header, its first plain-HTTP request can be intercepted. RFC 6797 calls this the "Bootstrap MITM Vulnerability". The HSTS preload list, built into browsers, closes it for listed domains. hstspreload.org's submission requirements are a valid certificate; a redirect from HTTP to HTTPS on the same host if port 80 is listening; HTTPS on all subdomains, including www if it exists in DNS; and an HSTS header on the base domain over HTTPS with "max-age must be at least 31536000 seconds (1 year)", includeSubDomains and preload. Any further redirect from the HTTPS site "must still have the HSTS header".

As of October 2026, hstspreload.org says that "Many browsers (Chrome, Safari) will automatically upgrade all HTTP navigations to HTTPS, regardless of the domain's HSTS policy," and concludes: "The benefits provided by HSTS preloading are minimal compared to the benefits provided by HSTS. While HSTS is recommended, HSTS preloading is not recommended." A missing preload directive is not a finding.

The cost of includeSubDomains and preload

Both commit every name under the domain to valid HTTPS. hstspreload.org stresses that this "applies to all subdomains, including internal subdomains that are not publicly accessible." The HTTP-only intranet tool under corp.example.com, the printer interface, the appliance with a self-signed certificate: once a browser holds the policy, none of them loads, and the user cannot click through the error.

Removal is slow. "Be aware that inclusion in the preload list cannot easily be undone. Domains can be removed, but it takes months for a change to reach users with a Chrome update and we cannot make guarantees about other browsers." A header-only policy unwinds as cached entries expire, which with a two-year max-age can also take a long time. hstspreload.org suggests ramping max-age in stages, starting with five minutes, then a week, then a month, checking for breakage at each stage. The inventory of subdomains has to come first, and it has to include the ones nobody remembers.

X-Content-Type-Options: nosniff

Browsers historically guessed a response's type from its content when the declared Content-Type looked wrong. That guessing is what makes MIME-confusion attacks work: an uploaded file served as text/plain gets loaded by a script tag and executed, which is the "JavaScript-parseable responses" bypass from the CSP paper.

The WHATWG Fetch Standard defines what nosniff does, and it is narrower than its name suggests. With the header set, a response is blocked if the request destination is script-like and the MIME type "is failure or is not a JavaScript MIME type", or if the destination is a stylesheet and the type is not text/css. "Only request destinations that are script-like or "style" are considered as any exploits pertain to them." MDN adds that it also stops the browser treating a response as HTML in a browsing context when Content-Type is absent or names a non-HTML type.

Two details matter from outside. Fetch reads only the first value: if the first item is not an ASCII case-insensitive match for "nosniff", the header has no effect. And nosniff enforces the Content-Type you send, so it helps only if that type is correct. OWASP's recommendation starts with "Set the Content-Type header correctly throughout the site." The responses where it matters most are user uploads, downloads and API responses that echo input, not the home page.

Referrer-Policy

When a user follows a link or a page loads a subresource, the browser sends a Referer header. If URLs carry tokens, such as password-reset links, invite codes or session identifiers in the query string, the referrer can leak them to third parties.

Older header advice predates a change here. Per MDN, strict-origin-when-cross-origin "is the default policy if no policy is specified, or if the provided value is invalid (see spec revision November 2020). Previously the default was no-referrer-when-downgrade." Under that default, cross-origin requests carry only the origin, and nothing is sent from HTTPS to HTTP. OWASP's cheat sheet confirms modern browsers use this default and still recommends setting the header explicitly, which is reasonable for clarity and for older clients.

So a missing Referrer-Policy header is, in a current browser, the default policy. The finding worth raising is a header that weakens the default: unsafe-url, which MDN warns "will leak potentially-private information from HTTPS resource URLs to insecure origins", or no-referrer-when-downgrade, which sends full URLs to every HTTPS third party. Here the header being present is the problem.

An outside check reading headers also misses part of the picture. Policy can be set in a meta element, per element with a referrerpolicy attribute, or per link with rel="noreferrer". A page with no header but a strict meta policy, or with a header but leaky per-link attributes, needs the HTML read too.

Permissions-Policy

MDN marks Permissions-Policy as "Limited availability" (not Baseline) and as experimental, so support varies by browser. Where it is supported, it controls which browser features, such as camera, microphone, geolocation and payment, a document and its frames may use. OWASP's reason for setting it is containment: configure the site "to never allow the camera or microphone to be activated. This prevents that an injection, for example an XSS, enables the camera, the microphone, or other browser feature." It also limits what a third-party iframe you embed can request.

Each feature has a default allowlist when unlisted, one of *, self or none according to MDN, and a frame can use a feature only if the parent's policy allows its origin, so the header sets the ceiling for every iframe on the page. The syntax is easy to get wrong: in the header, origins are quoted inside parentheses (geolocation=(self "https://maps.example.com")), while in an iframe allow attribute they are not. A value copied from one place to the other may not mean what it appears to say.

On its own, a missing Permissions-Policy is usually a hardening item, not a vulnerability. It matters more on pages that embed third-party content or that handle sensitive features.

COOP, COEP and CORP, briefly

These three exist mainly for cross-origin isolation, against side channels such as Spectre and the cross-site leak family:

OWASP notes these "may not make sense to be applied to REST APIs or clients that are not browsers." Treat their absence as a hardening opportunity, not a vulnerability, unless the application handles data that cross-site leaks would expose.

Headers to remove, not add

Some checklists still ask for headers that browsers have abandoned. A few of these do harm.

CORS is not a hardening header

Access-Control-Allow-Origin is sometimes listed alongside the headers above. It is the opposite kind of header. OWASP: "If you don't use this header, your site is protected by default by the Same Origin Policy (SOP). What this header does is relax this control in specified circumstances." The secure default is absence. Every CORS header grants something, and the question is whether the grant is intended.

The WHATWG Fetch Standard's CORS check is short enough to reason about directly:

So a wildcard cannot be combined with credentials. MDN: "Attempting to use the wildcard with credentials results in an error," and the browser "will block access to the response". Access-Control-Allow-Origin: * on a public, unauthenticated resource is often exactly right; OWASP notes it "might be necessary" for a public API.

Because the wildcard does not work with credentials, for credentialed cross-origin requests from several front ends, one workaround is code that copies the request's Origin header into Access-Control-Allow-Origin and adds Access-Control-Allow-Credentials: true. That allows any website the victim visits to make authenticated requests to your API and read the responses. Fetch describes sharing credentialed responses as "rather unsafe", requiring care "to avoid the confused deputy problem". That reflection is the CORS finding that matters, and it is easy to confirm from outside: send a request with Origin: https://unrelated.example and see whether the response names it and allows credentials.

The allowlist variants fail in familiar ways:

What an outside check can conclude, and what it cannot

Everything above can be observed by anyone who sends an HTTP request. That makes header analysis a natural part of external attack-surface work, and it also means the limits apply to every tool that does it.

One GET of / is not the site

Headers are attached per response, and many layers decide them:

OWASP's cheat sheet observes that "Online tools usually test the homepage of the given address." A useful external header check therefore fetches more than one path per host, records the status code, and treats different results on the same host as a finding in their own right.

Present is not effective, and absent is not exploitable

The configurations above that look present and do nothing:

And the absences that are not findings: no CSP on a JSON API, no X-Frame-Options on a redirect, no Referrer-Policy in a browser whose default is already strict, no preload directive.

A header says nothing about whether the application is injectable

A strong CSP on an injectable page means the injection is harder to exploit, not that it is absent. A missing CSP on a page with no injection point is an absent second layer, not a vulnerability. Header analysis tells you about the browser-side controls on a response. It cannot tell you whether there is anything for them to contain. That needs testing the application itself, from inside its code or by exercising it.

A grade is not a risk

A letter grade from any header grader is a summary of which headers were present on the responses it fetched, plus whatever value checks it applies. That makes it useful for a quick look, but by construction it can give a high grade to a site whose CSP is a bypassable allowlist, and a low grade to a static brochure site with no forms, no cookies and nothing to inject. Use grades to compare a site with itself over time, not as a measure of exposure.

Why evidence matters for every header finding

"Missing X-Frame-Options" means nothing on its own, because nobody can act on it. The owner needs to know which response: the exact URL, the status code, the time, the request that produced it (method, any Origin header sent, the address that answered), and the raw response headers as received. With that, the owner can see it was the 404 page, or the /account/ path, or one edge location, and can verify the fix by repeating the same request. Without it, the developer fetches /, sees the header, and closes the ticket as a false positive. The post on why an EASM finding needs evidence makes the general case; header findings are a clear case, because the same host gives different answers to different requests.

A checklist

  1. Inventory hosts first. Every header policy, and HSTS above all, is per host name. You cannot reason about includeSubDomains without the list of subdomains, internal ones included.
  2. Fetch the paths that matter, not just /. Login, account, admin, upload and download endpoints, a 404, and an API route, on every host.
  3. Set headers at one layer, for every status code. In nginx, use always and avoid add_header in nested blocks unless you repeat the full set (nginx 1.29.3 and later add add_header_inherit to change this). Check CDN and load-balancer error pages separately.
  4. Replace allowlist CSPs with a strict policy: a per-response nonce or hashes, 'strict-dynamic', object-src 'none' and base-uri 'none'. Confirm the nonce changes between two fetches of the same URL.
  5. Move Report-Only to enforcement once reports are clean, with a working reporting endpoint while you do.
  6. Put frame-ancestors in an enforced header policy, with the narrowest list that works. Keep X-Frame-Options: DENY or SAMEORIGIN consistent with it. Remove ALLOW-FROM.
  7. Send HSTS over HTTPS on every host, once, with a long max-age, ramped up in stages. Add includeSubDomains only after every subdomain, internal ones included, serves valid HTTPS. Treat preload as optional and slow to reverse.
  8. Send nosniff with correct Content-Type values, especially on uploads, downloads and API responses.
  9. Check Referrer-Policy for weakening values (unsafe-url, no-referrer-when-downgrade), and check meta tags and link attributes too.
  10. Set Permissions-Policy to disable features you do not use, especially on pages that embed third-party frames.
  11. Remove X-XSS-Protection (or set it to 0), Expect-CT and Public-Key-Pins.
  12. Audit CORS by sending a foreign Origin. No reflection with credentials, no null, exact-match allowlists, and Vary: Origin wherever the value changes.
  13. Keep the evidence: URL, status, time, request and raw headers for every finding, and re-check with the same request after a fix.

Where Maphra fits

Maphra is an external attack surface management platform. It starts from a domain name, enumerates subdomains and hosts, and attributes each discovered asset back to the organisation that owns it. Its security analysis covers TLS and certificate posture, security headers, DNS hygiene, email authentication and misconfiguration checks across every discovered asset, and each finding carries the raw response that produced it. Discovery re-runs on a schedule and is diffed against the last known surface, and findings are deduplicated across scans and tracked through new, reopened and fixed states.

That is the whole claim. This post does not claim that Maphra evaluates whether a CSP allowlist is bypassable, applies browser precedence between frame-ancestors and X-Frame-Options, detects reused nonces, tests CORS origin reflection, or fetches every path or status code on a host. The mechanisms and checks above are general practice, written so that you can apply them with any tool, or with curl and a list of URLs.

A header in a response says what a browser was asked to do with that response. Whether it was the right request, on the right response, for a page that needed it, depends on the URL, the status code and the time it was fetched.

More about Maphra · All posts