H4CK0R Network Security · Recon · Education

📋 HTTP Security Headers, Explained Header by Header

HTTP Security Headers, Explained Header by Header

A response header is a one-line instruction to the browser. It costs nothing to send, applies to every user on every request, and is the only place a server gets to constrain what the browser will do with the page it just delivered. That makes headers the cheapest security control in the stack — and the one most often set by copy-paste.

They are mitigations, not fixes: they reduce the impact of a flaw that already exists. A perfect Content-Security-Policy on a site with SQL injection is still a compromised site. Read every header below as "what happens when something else has already gone wrong".

Strict-Transport-Security (HSTS)

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

Defined in RFC 6797. It tells the browser: for the next max-age seconds, never speak plain HTTP to this host — rewrite http:// to https:// before the request leaves the machine. 31536000 is 365 days. includeSubDomains extends the rule to every subdomain of the host that sent it. The header is only honoured when received over a valid HTTPS connection; sending it over HTTP does nothing, by design.

What it stops: SSL stripping — the classic attack where someone on the same network intercepts the initial http:// request and proxies it. It also removes the "click through the certificate warning" option: once a host is known to the HSTS store, TLS errors on it are non-bypassable in mainstream browsers.

What it does not stop: the first ever request to the host, before any HSTS header has been seen. That trust-on-first-use gap is the whole reason preloading exists. It also does nothing about a certificate that is validly issued but issued to the wrong party — that is a Certificate Transparency and CAA problem, not an HSTS one.

The preload one-way door. Adding the preload token and submitting the domain to the Chromium HSTS preload list bakes the host into browser binaries, closing the first-visit gap. Entry requires max-age of at least 31536000, includeSubDomains, the preload token, valid HTTPS on the base domain and an HTTP-to-HTTPS redirect on the same host. Removal is the problem: a delisting request has to propagate through the release cycle of every browser that ships the list — months, not minutes. And because includeSubDomains is mandatory, every subdomain that exists now or later (the legacy intranet host, the vendor's HTTP-only status page) must serve valid HTTPS or become unreachable. Preload when the whole estate is ready, not before. check a domain's HSTS and preload status →

Content-Security-Policy

CSP is an allowlist for the resources a document may load and execute. It is the most powerful header here and the most commonly neutered.

DirectiveControls
default-srcFallback for most fetch directives that are not explicitly set.
script-srcWhere JavaScript may come from. The directive that decides whether CSP mitigates XSS at all.
style-src, img-src, font-src, media-srcPer-type resource origins.
connect-srcTargets for fetch, XHR, WebSocket, EventSource, sendBeacon — the exfiltration channel.
object-srcPlugin content. Set to 'none'; legacy plugin types are a script-execution path.
base-uriRestricts <base href>. Without it, an injected base tag can repoint every relative script URL at an attacker.
form-actionWhere forms may submit. Stops injected forms posting credentials off-site.
frame-ancestorsWho may frame this page — the anti-clickjacking directive.
frame-src / worker-srcWhat may be framed, and where workers may be loaded from.
upgrade-insecure-requestsRewrites http:// subresource URLs to HTTPS before fetching.
report-uri / report-toWhere violation reports are sent. report-uri is deprecated but still the widest-supported; many sites send both.

Why 'unsafe-inline' defeats the point. Most XSS payloads are inline: an injected <script> block, or an onerror= attribute on an injected <img>. script-src 'self' 'unsafe-inline' allows exactly those. The policy still restricts which external hosts may be loaded, which is worth something, but it no longer blocks the common case. The same goes for 'unsafe-eval' and eval()-shaped sinks.

Nonces, hashes, strict-dynamic. A nonce is a per-response random value, at least 128 bits from a CSPRNG, emitted in the policy as 'nonce-{RANDOM}' and repeated on each legitimate tag as <script nonce="{RANDOM}">. An injected script cannot know it, so it does not run. Two rules: the value changes on every response (so a nonce-bearing page must not be stored by a shared cache), and it is never predictable. A hash — 'sha256-BASE64DIGEST' over the exact script body — is the static alternative: no per-request work, but it breaks when a byte of that script changes. 'strict-dynamic' (CSP Level 3) says any script already trusted by a nonce or hash may load further scripts, and host allowlists in that directive are ignored. That last clause is the point — allowlists are routinely bypassable through a JSONP endpoint or a hosted framework file on an allowlisted CDN, so 'strict-dynamic' retires the allowlist rather than curating it.

Content-Security-Policy:
  object-src 'none';
  base-uri 'none';
  script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
  report-uri /csp-report

The trailing https: and 'unsafe-inline' look wrong and are not: a browser that understands nonces ignores 'unsafe-inline' whenever a nonce or hash is present, and one that understands 'strict-dynamic' ignores https:. They are fallbacks for older engines. Note too that multiple CSP headers are each enforced independently — the effect is their intersection, so a second policy can never loosen the first.

Report-only mode. Content-Security-Policy-Report-Only has identical syntax, blocks nothing and reports violations. Ship it alongside the enforcing header, read the reports for a release or two, then promote. Expect noise from browser extensions injecting into your pages; that noise is not your bug.

frame-ancestors supersedes X-Frame-Options. frame-ancestors 'none' — or frame-ancestors 'self' https://partner.example — replaces X-Frame-Options: DENY|SAMEORIGIN, and where both appear, browsers supporting CSP are specified to prefer frame-ancestors. X-Frame-Options: ALLOW-FROM was never broadly implemented, so CSP is the only working way to allow specific framers. Keeping X-Frame-Options: DENY alongside costs nothing for old clients.

What CSP does not stop: server-side flaws of every kind, and it is no substitute for output encoding. Exfiltration is constrained but not eliminated — a policy allowing any image host allows a beacon. require-trusted-types-for 'script', which makes DOM injection sinks fail closed, is strong but currently Chromium-only: a bonus, not a control you can rely on for every user. build a policy interactively →

X-Content-Type-Options

X-Content-Type-Options: nosniff

nosniff is the only valid value. It stops the browser guessing a response's type from its bytes and forces it to honour the declared Content-Type. Without it, a user-uploaded file served as text/plain that happens to contain markup can be sniffed into HTML and executed in your origin; with it, a script or stylesheet whose declared type does not match is refused. It does not repair a wrong Content-Type — serve JSON as text/html and nosniff will not save you. Set it on every response, including APIs, error pages and static files, and pair it with Content-Disposition: attachment on user-uploaded downloads.

Referrer-Policy

The Referer header (misspelled since 1996) leaks the URL you came from. On a page whose URL contains a password-reset token, a case number or a search term, that is a data leak to every third-party asset on the page.

ValueSame-originCross-originHTTPS → HTTP
no-referrernothingnothingnothing
no-referrer-when-downgradefull URLfull URLnothing
originoriginoriginorigin
origin-when-cross-originfull URLoriginorigin
same-originfull URLnothingnothing
strict-originoriginoriginnothing
strict-origin-when-cross-originfull URLoriginnothing
unsafe-urlfull URLfull URLfull URL

"origin" means scheme, host and port only — https://example.com/ with no path or query. strict-origin-when-cross-origin is the modern browser default and the right production value: internal navigation keeps full referrers for analytics, external requests only learn your origin, and nothing at all crosses a downgrade. Use no-referrer for pages that hold secrets in the URL. Never use unsafe-url.

Permissions-Policy

Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=(), usb=()

Successor to Feature-Policy. The syntax is structured fields: each feature takes an allowlist in parentheses. () is empty — the feature is disabled for the document and every frame inside it, including your own code. (self) allows the document's own origin, (self "https://maps.example.com") adds a specific one, * allows everyone. Quoting matters: origins are quoted strings, self and * are not.

Think of it as blast-radius reduction. If a third-party script or an embedded iframe is compromised, it still cannot ask for the camera, because the feature was never delegated to it. It says nothing about capabilities that need no permission, such as reading the DOM or making same-origin fetches.

The cross-origin trio: COOP, COEP, CORP

HeaderValuesEffect
Cross-Origin-Opener-Policysame-origin, same-origin-allow-popups, unsafe-nonePuts the document in its own browsing-context group, severing the window.opener reference to and from cross-origin documents.
Cross-Origin-Embedder-Policyrequire-corp, credentialless, unsafe-noneRefuses to load cross-origin subresources unless they opt in via CORP or CORS. credentialless loads them without cookies instead of requiring opt-in.
Cross-Origin-Resource-Policysame-origin, same-site, cross-originDeclares who is allowed to embed this resource. Applied to your own responses, it blocks other sites from pulling them into their process.

COOP alone is worth setting on any authenticated page: it kills the family of cross-window and cross-site-leak tricks that depend on holding a handle to your window. COOP: same-origin plus COEP: require-corp puts the document into cross-origin isolation, visible in JavaScript as crossOriginIsolated === true — the state that unlocks SharedArrayBuffer and performance.measureUserAgentSpecificMemory(), both gated behind isolation after the Spectre class of side-channel attacks. If you do not need those APIs, COOP plus CORP is the low-friction pair; COEP is the one that breaks third-party embeds, so roll it out with its report-only variant first.

Cache-Control on authenticated responses

Cache-Control: no-store

The distinction that catches people out: no-cache does not mean "do not cache" — it means "store it, but revalidate with the origin before reusing it". private means "shared caches must not store this, the browser may". Only no-store forbids writing the response to any cache at all, which is what keeps an account page out of the disk cache on a shared machine and out of an intermediary proxy. Use it on any response carrying personal data, balances or tokens.

Two consequences. no-store on an HTML document disables the back/forward cache in some browsers, so navigation feels slower — that is the trade. And do not apply it globally: static assets want Cache-Control: public, max-age=31536000, immutable with a content hash in the filename. Where a cached route varies by identity, add Vary: Cookie so a shared cache cannot serve one user's copy to another. On logout, Clear-Site-Data: "cache", "cookies", "storage" asks the browser to drop local state for the origin.

Headers to remove

These are information disclosure, not vulnerabilities — an attacker who wants your PHP version can fingerprint it other ways. But publishing an exact build number turns "is this host worth attacking" into a lookup, so remove them.

# Apache — trims "Server: Apache/2.4.58 (Debian)" down to "Server: Apache"
ServerTokens Prod
ServerSignature Off
Header always unset X-Powered-By

# nginx — removes the version from "Server: nginx/1.24.0"
server_tokens off;

# PHP — removes X-Powered-By: PHP/8.3.x entirely
expose_php = Off        ; php.ini
header_remove('X-Powered-By');   // or at runtime

Strip framework banners too — X-AspNet-Version, X-AspNetMvc-Version, X-Generator. Note that neither Apache nor nginx will delete the Server header outright from their own configuration; they only shorten it. Removing it entirely takes a module such as mod_security's SecServerSignature, or a reverse proxy or CDN that rewrites it. One header not to resurrect: X-XSS-Protection. The auditor it controlled has been removed from mainstream browsers and its filtering introduced information-leak bugs of its own — send X-XSS-Protection: 0 or omit it, and spend the effort on CSP.

A baseline set, with a comment per line

Strict-Transport-Security: max-age=31536000; includeSubDomains
    # 1 year, all subdomains. Add "preload" only when every subdomain is HTTPS-ready.

Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'none';
  frame-ancestors 'none'; form-action 'self';
  script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline'
    # Nonce regenerated per response. Ship as -Report-Only first and read the reports.

X-Content-Type-Options: nosniff
    # Stop MIME sniffing. Every response, no exceptions.

X-Frame-Options: DENY
    # Redundant with frame-ancestors on modern browsers; harmless legacy backstop.

Referrer-Policy: strict-origin-when-cross-origin
    # Full URL internally, origin only externally, nothing on a downgrade.

Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=(), usb=()
    # Deny powerful features outright; add (self) back only where actually used.

Cross-Origin-Opener-Policy: same-origin
    # Cuts the window.opener link to cross-origin documents.

Cross-Origin-Resource-Policy: same-origin
    # Other sites may not embed these responses.

Cache-Control: no-store
    # AUTHENTICATED HTML AND JSON ONLY. Static assets get public, max-age, immutable.

Set them with always in nginx (add_header ... always;) or Apache (Header always set ...) so they also appear on 4xx and 5xx responses — an error page is still a page. Watch one nginx trap: add_header directives are not merged across nesting levels, so a single add_header inside a location block silently drops every header inherited from server. Verify the result on the wire rather than in the config. grade a live host's headers → or inspect the raw response and redirect chain →

Key takeaways
  • Headers mitigate; they do not fix. Rank them below fixing the underlying bug, above almost everything else by cost/benefit.
  • max-age=31536000 is one year. HSTS preload is slow to undo and forces HTTPS on every subdomain — deliberate decision, not a default.
  • A CSP containing 'unsafe-inline' in script-src does not block the common XSS case. Nonce plus 'strict-dynamic', and object-src 'none' with base-uri 'none'.
  • frame-ancestors is the modern clickjacking control; X-Frame-Options is the legacy backstop.
  • no-cache still stores. Authenticated responses need no-store.
  • Set headers with always so error responses carry them too, and verify on the wire.