HTTP Security Headers, Explained: The Browser Defenses You Have to Ask For
Browsers can block injected script, refuse framing, insist on HTTPS and guard cookies, but only when the response asks. Here is what each security header does, what a missing header or a mislabeled response costs, and a configuration that holds.
The browser, which enforces only the policies a response declares
Required condition
Responses omit or weaken security headers and cookie attributes, or declare a Content-Type that does not match the body.
Potential impact
Missed injection flaws become script execution, plus clickjacking, HTTPS downgrade, cookie theft and content-type confusion.
Primary defense
One central header policy on every response: nonce-based CSP, frame-ancestors, HSTS, nosniff with accurate types, and Secure, HttpOnly, SameSite cookies.
Safe practice: use benign proof only, in a disposable local lab or on a system you are explicitly authorized to test.
On this page
A browser ships with defenses most applications never switch on. It can refuse to run script the server did not approve, refuse to be framed, refuse plain HTTP to your domain and keep session cookies away from script and cross-site requests. Each is opt-in: the response has to ask, with a header or a cookie attribute. Otherwise the browser falls back to permissive defaults built for compatibility.
OWASP Top 10:2025 names a server that does not send security headers, or sets them to insecure values, as a sign of A02 Security Misconfiguration, and maps the cookie weaknesses CWE-614 and CWE-1004 to the same category. Missing headers rarely form an exploit on their own. They decide how much damage the next bug can do.
The mental model: the response sets the browser's rules
Header or attribute
What the browser enforces
Without it
Content-Security-Policy
Which scripts may run, and who may frame the page
One missed XSS sink runs any script
Strict-Transport-Security
HTTPS only for the host, after the first visit
A plain http:// request can be intercepted and downgraded
X-Frame-Options
Framing control for browsers without frame-ancestors
Clickjacking
X-Content-Type-Options: nosniff
The declared Content-Type is final
Content runs or renders as a type nobody intended
Referrer-Policy
How much of the URL leaves in Referer
Browser defaults decide; older ones send full URLs
Permissions-Policy
Which powerful features the page may use
Injected script can request camera or location under your name
Cross-Origin-Opener-Policy
Whether cross-origin windows keep a handle to yours
Cross-window state leaks
Cookie Secure, HttpOnly, SameSite
When a cookie travels and whether script can read it
Theft over HTTP or by script; cross-site requests
Content-Type belongs on the list too: nosniff makes the browser believe the label instead of guessing, so the label must be right. SelfSec reports a mislabeled response as CWE-436, Interpretation Conflict.
How it happens
Headers go missing for unglamorous reasons. The application sets them in middleware while the CDN, proxy and static file server build their own responses. Error pages and redirects skip the code path that adds them. A policy carries 'unsafe-inline' because the page once had inline scripts. Deprecated headers such as X-XSS-Protection: 1; mode=block and Expect-CT survive years after browsers stopped honoring them.
Content types go wrong the same way. Express picks the type from the argument: res.send() with a string sets text/html, so this handler serves JSON labeled as HTML:
Against that one response, SelfSec's Security Headers checks alone raise seventeen findings: five missing headers, two revealing ones, an enabled X-XSS-Protection, four CSP weaknesses ('unsafe-inline', an unpinned host, no object-src 'none', no base-uri), two HSTS weaknesses and three missing cookie attributes.
Next, the tester compares labels with bodies. The search endpoint answers with text/html and a body that starts with {. A harmless marker shows why that matters:
GET /api/search?q=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E HTTP/1.1
Host: app.example
The body is {"q":"<img src=x onerror=alert(1)>","results":[]}, and the browser parses it as the HTML it was told it is: the image fails to load and alert(1) runs. JSON encoding does not escape <, so the label is all that separates data from markup. Confirm the dialog in a real browser, then stop.
What an attacker gains
Script execution from a smaller bug: without an enforced CSP, any missed sink in cross-site scripting runs whatever the attacker writes, and a JSON endpoint labeled as HTML is such a sink.
Clickjacking: a decoy page frames a signed-in screen invisibly and lines up the victim's click with a button such as "delete account".
HTTPS downgrade: without HSTS, a typed hostname or an old http:// link starts in plain text, and a hostile network can keep the victim there; see TLS misconfiguration.
Cookie theft and misuse: a cookie without Secure can cross that plain connection, one without HttpOnly is readable by injected script, and one without SameSite rides along on cross-site requests.
Fingerprinting:Server: nginx/1.24.0 and X-Powered-By narrow the search for known flaws; see information disclosure.
Reading a SelfSec header finding
The checks are passive and report every gap per URL, so one site-wide omission becomes many findings that one central fix clears. Worth knowing:
Only response headers count. A CSP in a <meta> tag is reported as missing, and browsers ignore frame-ancestors there anyway. Content-Security-Policy-Report-Only does not count, because it enforces nothing.
A missing HSTS header on an HTTPS URL is also reported by SSL/TLS Analysis, at low severity, so the same gap can appear twice; see TLS misconfiguration.
A missing X-Frame-Options is reported even when frame-ancestors is set. Browsers that support frame-ancestors ignore the older header, so sending both costs nothing.
'unsafe-inline' is reported even beside a nonce. Modern browsers ignore it there; it only helps very old ones.
HttpOnly is checked on every cookie. A preference cookie your own script reads can be a deliberate exception; a session cookie cannot.
The mismatch check catches two shapes: text/html with a body starting with { or [, and application/json with one starting with <. The second is usually an HTML error page from a proxy or framework in front of a JSON API.
Password fields are checked by a separate passive module, Password Autocomplete, which reports password inputs not marked autocomplete="off" or autocomplete="new-password"; see password autocomplete.
The fix: one header policy for every response
Set headers where every response passes, errors and redirects included. For static content on nginx:
always adds the headers to 4xx and 5xx responses too, and server_tokens off drops the version from Server. Pages with inline script need a nonce minted per response, which only the application can do. In ASP.NET Core, register this first so error pages carry the same headers:
Views write nonce="@Context.Items["csp-nonce"]" on each script tag, and 'strict-dynamic' extends that trust to the scripts they load. Without MaxAge, UseHsts sends 30 days. Add preload only once every subdomain serves HTTPS; leaving the preload list takes months. A page that opens a cross-origin sign-in popup and must keep a handle to it can use same-origin-allow-popups.
In Express, return JSON through res.json(), which sets application/json so the earlier request renders as inert text, and drop the framework banner:
Headers on the happy path only: without always, nginx skips error responses, and one add_header inside a location block drops every header inherited from server.
Two layers, two policies: browsers enforce every CSP they receive, so a static edge policy blocks the inline scripts the application's nonce approves.
A host allowlist:script-src https://cdn.example trusts every file on that host, including JSONP endpoints and old library versions.
X-XSS-Protection: 1; mode=block: modern browsers removed the filter it controlled, and older ones could be abused to block legitimate scripts. Send 0 or nothing.
ALLOW-FROM: ignored by modern browsers, which leaves the page frameable; list partners in frame-ancestors.
Counting on nosniff to fix a label: it makes the browser trust the declared type, so JSON served as text/html is still HTML.
A developer review checklist
Choose one layer to own each header and confirm it covers redirects, 404s and 500s.
Deploy CSP with per-response nonces or hashes plus 'strict-dynamic', object-src 'none', base-uri 'none' and frame-ancestors; run it as Report-Only first, then enforce.
Send X-Frame-Options: DENY or SAMEORIGIN alongside frame-ancestors.
Serve HSTS with max-age=31536000; includeSubDomains once every subdomain supports HTTPS.
Give session cookies the __Host- prefix with Secure, HttpOnly and SameSite=Lax or Strict; never send SameSite=None without Secure.
Return JSON through the framework's JSON helper and add nosniff; never let a generic send pick the type.
Remove X-Powered-By, Expect-CT and version numbers in Server, then rescan.
Module: Security Headers and Content Type Mismatch. How it probes: Passive: no payload is sent. After the crawl each discovered URL is fetched again with a plain GET, and its headers and Set-Cookie lines are checked for missing CSP, X-Frame-Options, HSTS, X-Content-Type-Options, Referrer-Policy and Permissions-Policy; on HTML, a missing or unsafe-none Cross-Origin-Opener-Policy and CSP script sources with 'unsafe-inline', 'unsafe-eval', a wildcard or a host allowlist without nonce, hash or 'strict-dynamic', and no object-src 'none' or base-uri; HSTS under one year or without includeSubDomains; deprecated or revealing headers; and cookies lacking HttpOnly, Secure or SameSite or breaking the SameSite=None and __Host-/__Secure- rules. Content Type Mismatch flags text/html bodies that start with { or [ and application/json bodies that start with <. How it confirms: Nothing is replayed: the response is the evidence. Each gap is its own finding on the URL that returned it, with the request and response attached and, where a value is at fault, the offending header, cookie or Content-Type comparison. Missing CSP, X-Frame-Options or HSTS, 'unsafe-inline', 'unsafe-eval' or wildcard script sources, a missing or zero HSTS max-age and most cookie problems are medium; other gaps and content-type mismatches are low, and a preload-ready HSTS policy without preload is informational. A finding moves from Possible to Confirmed only after this check. Reported as CWE-693, CWE-1021, CWE-319, CWE-614, CWE-1004, CWE-1275, CWE-200, CWE-436 · ATT&CK T1213 · SARIF 2.1.0.
How SelfSec proves this one
PossibleConfirmed
Module
Security Headers and Content Type Mismatch
How it probes
Passive: no payload is sent. After the crawl each discovered URL is fetched again with a plain GET, and its headers and Set-Cookie lines are checked for missing CSP, X-Frame-Options, HSTS, X-Content-Type-Options, Referrer-Policy and Permissions-Policy; on HTML, a missing or unsafe-none Cross-Origin-Opener-Policy and CSP script sources with 'unsafe-inline', 'unsafe-eval', a wildcard or a host allowlist without nonce, hash or 'strict-dynamic', and no object-src 'none' or base-uri; HSTS under one year or without includeSubDomains; deprecated or revealing headers; and cookies lacking HttpOnly, Secure or SameSite or breaking the SameSite=None and __Host-/__Secure- rules. Content Type Mismatch flags text/html bodies that start with { or [ and application/json bodies that start with <.
How it confirms
Nothing is replayed: the response is the evidence. Each gap is its own finding on the URL that returned it, with the request and response attached and, where a value is at fault, the offending header, cookie or Content-Type comparison. Missing CSP, X-Frame-Options or HSTS, 'unsafe-inline', 'unsafe-eval' or wildcard script sources, a missing or zero HSTS max-age and most cookie problems are medium; other gaps and content-type mismatches are low, and a preload-ready HSTS policy without preload is informational.
A reflected response alone is not enough to promote a Security Headers finding. The classical engine has to confirm the behavior before it reaches the report. See the detection engine
Reading about the class is one thing. SelfSec tests for it against an application you are authorized to assess, and only calls it real when independent evidence agrees.