Skip to content
Security Headers

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.

Part 1 of 2From the Security Headers series

At a glance

CWE-693
Interpreter
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:

app.get('/api/search', (req, res) => {
  res.send(JSON.stringify({ q: req.query.q, results: [] }));
});

A concrete test: read the headers, then the labels

An authorized tester starts with curl -sI https://app.example/ and reads what comes back:

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Server: nginx/1.24.0
X-Powered-By: Express
X-XSS-Protection: 1; mode=block
Content-Security-Policy: script-src 'self' 'unsafe-inline' https://cdn.example
Strict-Transport-Security: max-age=86400
Set-Cookie: session=4c1d9a0e; Path=/

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:

server {
    listen 443 ssl;
    server_name app.example;
    ssl_certificate /etc/ssl/app.example.crt;
    ssl_certificate_key /etc/ssl/app.example.key;
    root /srv/app/public;
    server_tokens off;

    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-Frame-Options "DENY" 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 Cross-Origin-Opener-Policy "same-origin" always;
}

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:

using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.Cookies;

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(365);
    options.IncludeSubDomains = true;
});
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = "__Host-session";
        options.Cookie.Path = "/";
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.HttpOnly = true;
        options.Cookie.SameSite = SameSiteMode.Lax;
    });

var app = builder.Build();
app.Use(async (context, next) =>
{
    string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
    context.Items["csp-nonce"] = nonce;
    context.Response.OnStarting(() =>
    {
        IHeaderDictionary headers = context.Response.Headers;
        headers["Content-Security-Policy"] =
            $"script-src 'nonce-{nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'";
        headers["X-Frame-Options"] = "DENY";
        headers["X-Content-Type-Options"] = "nosniff";
        headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
        headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
        headers["Cross-Origin-Opener-Policy"] = "same-origin";
        return Task.CompletedTask;
    });
    await next(context);
});
app.UseHsts();

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:

app.disable('x-powered-by');

app.get('/api/search', (req, res) => {
  res.json({ q: String(req.query.q ?? ''), results: [] });
});

Fixes that do not hold

  • 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

  1. Choose one layer to own each header and confirm it covers redirects, 404s and 500s.
  2. 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.
  3. Send X-Frame-Options: DENY or SAMEORIGIN alongside frame-ancestors.
  4. Serve HSTS with max-age=31536000; includeSubDomains once every subdomain supports HTTPS.
  5. Give session cookies the __Host- prefix with Secure, HttpOnly and SameSite=Lax or Strict; never send SameSite=None without Secure.
  6. Return JSON through the framework's JSON helper and add nosniff; never let a generic send pick the type.
  7. Remove X-Powered-By, Expect-CT and version numbers in Server, then rescan.

References

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
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.
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

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

SelfSec scanner

Find Security Headers in your own app

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.

Find it Confirmed, not guessed 21 vulnerability modules, with the classical engine owning the verdict. Prove it Evidence you can re-read Every finding keeps the raw request, the response and the baseline it was measured against.
Join the launch list