HTTP Security Headers — A Second Line of Defense That Tells the Browser How to Defend Itself thumbnail

HTTP Security Headers — A Second Line of Defense That Tells the Browser How to Defend Itself

⏱ approx. 30 min views 70 likes 0 LOG_DATE:2026-05-22
TOC

HTTP security headers are "instructions to the browser" attached to the server's response. You can add directives like "require TLS on this site", "only execute these scripts", "don't let me be embedded in an iframe" without touching application code at all. The Same-Origin Policy (SOP) stops direct cross-origin access, but it's helpless against code injected into the same origin (XSS) or against your own origin being framed (clickjacking) — and these headers fill that gap. This article walks through HSTS → CSP → clickjacking → nosniff / Referrer → Permissions-Policy → cross-origin isolation → verification and operations, reading each header's "what it protects" and "how it breaks" as a single flow.

▸ For Web beginners — just take these 3 home first

The details are overwhelming, but the essence is just three takeaways. (1) Security headers are "defenses that run inside the browser". The server tells the browser "behave like this". (2) You can add them with one line of nginx config, no app code changes. Cheap to introduce, big payoff. (3) The most important are HSTS (force TLS) and CSP (script control). Everything else is supplementary. Anchor on these three and the rest of the chapters won't lose you.

01

Why "command the browser via headers"? #

Browsers are built to maintain compatibility with old HTML and old protocols. For example, by default clicking an HTTP link establishes an unencrypted connection, JavaScript fetched via <script src="..."> runs, and being embedded in someone else's <iframe> is allowed. These are correct as Web features, but to an attacker they are ready-to-use attack surface.

Security headers are the mechanism for the site operator to tell the browser "turn off the old behavior for our site". Instead of the server defending, the browser defends. So they only work for Web apps where the audience is "browser users" — curl and custom scripts ignore them. Conversely, they have massive effect specifically against attacks that come through browsers (XSS / clickjacking / MIME sniffing, etc.).

▸ Plain version — "the terms of service you hand to the browser"

Writing "do not forward unopened", "no copying" on the back of a postcard is meaningless if the carrier doesn't read it. HTTP security headers are "terms of service the browser actually reads and follows". The server says "never connect without TLS", "execute only these scripts", and modern browsers follow them quite strictly. So even an app where "nothing has been done on the server side" gets a sudden boost in defensive layers by adding 5 lines of headers.

Organized by attack:

Attack Header What it stops
HTTP downgrade / SSL stripping Strict-Transport-Security Plain HTTP connections in the first place
XSS (reflected / stored / DOM) Content-Security-Policy Execution of inline and external scripts
Clickjacking Content-Security-Policy: frame-ancestors / X-Frame-Options Embedding in cross-origin iframes
MIME-sniffing file-spoof execution X-Content-Type-Options: nosniff Execution of JS disguised as image/jpeg
Referer leakage Referrer-Policy Tokens / paths in URLs leaking out
Excessive privileges (camera / mic / payment) Permissions-Policy Third parties calling powerful APIs uninvited
Spectre / Meltdown family Cross-Origin-{Opener,Embedder,Resource}-Policy Cross-origin shared-memory / pointer leakage

These are mutually orthogonal — no single one is sufficient; only the full set forms a complete layered defense.

02

HSTS — making TLS non-negotiable #

HSTS (HTTP Strict Transport Security, RFC 6797) is a header that tells the browser to remember "never connect to this site over HTTP". Once issued, even if the user types http://example.com/login or clicks an http:// link in the URL bar, the browser rewrites it to https:// before contacting the server.

▸ Plain version — "we're not going back to HTTP"

HSTS is a sticky note that an HTTPS-accessed site places on the browser saying "always come over HTTPS from now on". On every subsequent visit, even if the user accidentally types `http://` or the attacker tries to redirect with `Location: http://...`, the browser rewrites to HTTPS first. In short, downgrade attacks (SSL stripping) are structurally blocked. Once received, the note is kept for the `max-age` window (typically 1–2 years), during which HTTP is never used.

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Directive Meaning
max-age=63072000 Seconds to remember this rule. The example is 2 years (recommended minimum: 1 year)
includeSubDomains Apply to all *.example.com as well
preload Declares intent to be added to the browser-bundled preload list

preload is opt-in — register at hstspreload.org and you get hard-coded into Chrome / Firefox / Safari, so HSTS applies to brand-new users who have never visited the site. The flip side: "once registered, removal is very heavy (list propagation takes months)", so register only after you're confident about running HTTPS-only in production.

Caution — HSTS pitfalls

(1) Once HSTS is issued, it cannot be cleared without serving a new HTTPS response with max-age=0. If you let HSTS stick on a site whose certificate later expires, the browser refuses non-HTTPS but HTTPS now shows a cert error — users lose their way to access. (2) After `preload` registration, all subdomains must be HTTPS too. Internal tools quietly serving HTTP all die simultaneously. Roll out gradually: short max-age (e.g., 5 min) → 1 day → 1 week → 1 year → preload.

There was also HPKP (Public-Key-Pins), a "certificate public-key pinning" header introduced around 2012, but misconfigurations frequently bricked sites permanently, and it was removed in Chrome 72 (2019). The current standard is "Certificate Transparency (CT) logs + CAA records"; header-based pinning is no longer recommended.

03

CSP — the script execution allowlist #

Content-Security-Policy (CSP) is the most powerful and the most difficult among security headers. It blocks XSS and inline-script-injection attacks via a whitelist approach: "only what is permitted runs". Even if the attacker injects <script>, the browser refuses to run anything CSP didn't allow.

▸ Plain version — "an execution allowlist"

The essence of XSS is that the attacker's script runs in the same origin as the site. CSP is the mechanism by which the server announces to the browser "only scripts from these URLs, and ones carrying this nonce, are allowed to run here". An injected inline `<script>alert(1)</script>` has no nonce, so it's quietly rejected before execution. Even if one spot of output escaping was forgotten, CSP works as a second wall.

Common directives:

Directive What it controls Example
default-src Catch-all for resources not specified elsewhere 'self'
script-src Where JS can come from 'self' 'nonce-abc123'
style-src Where CSS can come from 'self' 'unsafe-inline'
img-src Images 'self' data: https:
connect-src fetch / WebSocket / EventSource targets 'self' https://api.example.com
font-src Fonts 'self' https://fonts.gstatic.com
frame-src / child-src Origins iframes can load 'self'
frame-ancestors Origins allowed to iframe-embed you 'self'
form-action <form> submission targets 'self'
base-uri URLs the <base> tag may set 'self'
object-src <object> / <embed> (Flash, etc.) 'none'
report-to / report-uri Where to send violation reports https://example.com/csp-report

A reasonable modern baseline:

Minimal CSP
# allow only same-origin static assets, no objects, frame-ancestors limited to self Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random per request}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; report-uri /csp-report

The key is to go nonce-based. Using 'unsafe-inline' collapses CSP's defensive value — inline <script>...</script> becomes wide-open, so the moment XSS lands, CSP is effectively gone. Instead, generate a random nonce per request on the server and tag only your own script tags as <script nonce="abc123">. The attacker doesn't know the correct nonce, so they can't inject one.

Caution — `'unsafe-inline'` and `'unsafe-eval'`

`'unsafe-inline'` allows all inline scripts / inline styles; `'unsafe-eval'` permits eval() / new Function(). Both reduce CSP's defensive value to near-zero. Third-party tags like Google Tag Manager often request them, but migrate to nonce / hash when possible. A CSP with both is "policy exists but practically no guard", and Mozilla Observatory won't give it an A.

For phased introduction, use Content-Security-Policy-Report-Only. Same syntax, but it reports violations without blocking. Ship it to production, gather violations like an access log, understand what breaks when you remove 'unsafe-inline', then switch to the enforcing header. Always start new deployments with report-only.

Trusted Types (require-trusted-types-for 'script') is a CSP super-feature that forbids dangerous assignments like element.innerHTML = userInput at the type level. Chromium-only for now, but as the ultimate defense against DOM-Based XSS, it's seeing adoption at large sites (Gmail / Google Search).

04

Clickjacking — X-Frame-Options and frame-ancestors #

Clickjacking is an attack where the attacker overlays the victim site as a transparent <iframe> on top of the attacker's site, "hijacking" the user's click onto a different button. The victim thinks they clicked "Like", but behind the scenes they pressed Logout or Transfer.

▸ Plain version — "a transparent skin over someone else's site"

The attacker builds a fake site with a "Click to win" button, then overlays an opacity:0 iframe of the bank site's "Confirm Transfer" button pixel-perfectly on top of it. When the user presses "Win", they actually press the bank's transfer button through the iframe. If embedding in iframes is forbidden, this attack doesn't work, so declare "no iframe embedding" via header.

Two header families stop embedding.

X-Frame-Options: DENY              # no iframe from anywhere
X-Frame-Options: SAMEORIGIN        # iframe allowed only from same origin
Content-Security-Policy: frame-ancestors 'none'
Content-Security-Policy: frame-ancestors 'self' https://partner.example.com

X-Frame-Options is the older header and only takes DENY / SAMEORIGIN. CSP's frame-ancestors is its superset, accepting an allowlist of multiple origins. Setting both is fine; modern browsers ignore X-Frame-Options when frame-ancestors is present. For old-browser compatibility, having both is safer.

Use case Recommended setting
Never embeddable (banking / admin panels) X-Frame-Options: DENY + frame-ancestors 'none'
Embeddable only within own site X-Frame-Options: SAMEORIGIN + frame-ancestors 'self'
Allow specific partners only frame-ancestors 'self' https://partner.example.com (omit XFO or use SAMEORIGIN)

A third value ALLOW-FROM existed but neither Chromium nor Firefox implemented it, so it's effectively dead. "Allow iframe from a specific origin" must be done via frame-ancestors.

05

Controlling MIME sniffing and Referrer #

From here the headers are unglamorous but essential. They shave off small entry points; their presence is unflashy but there's no reason to skip them.

X-Content-Type-Options: nosniff #

By historical convention, browsers peek into binary content and guess its MIME type when the server-declared Content-Type "looks obviously wrong" (MIME sniffing). For example, a file declared image/jpeg whose contents start as JavaScript may be executed as JS depending on context. This is gold for attackers — upload JS as an "image" to an image-upload feature, then load it with <script src> is the standard pattern.

X-Content-Type-Options: nosniff

With this header, the browser completely disables sniffing and trusts the Content-Type at face value. There is no reason not to set it.

Referrer-Policy #

When clicking <a href> to navigate, the destination receives a Referer header indicating which URL you came from. To prevent URL tokens or internal tool paths from leaking, control the leak amount with this header.

Referrer-Policy: strict-origin-when-cross-origin
Value What is sent
no-referrer Nothing
same-origin Full URL only to same origin
strict-origin Only the origin (https://example.com) cross-origin
strict-origin-when-cross-origin Full URL same-origin, origin only cross-origin (modern browsers' default)
unsafe-url Always full URL (not recommended)

Modern browsers default to strict-origin-when-cross-origin when this header is unspecified, so even without it you're fairly safe, but being explicit is more robust against future behavior changes. For services that put session tokens or one-time-link tokens in URLs, lean toward no-referrer.

06

Permissions-Policy — narrowing the powerful APIs #

A header that allows Web APIs holding powerful privileges — camera, microphone, geolocation, payment, sensors — to only your own origin or specific partners. The predecessor was Feature-Policy, renamed in 2020 with cleaned-up syntax.

Permissions-Policy: camera=(), microphone=(), geolocation=(self), payment=(self "https://pay.example.com")
Feature Example Purpose
camera () to block all, (self) for same origin only Third-party tags can't suddenly start the camera
microphone Same Same
geolocation (self) Prevent ad SDKs from pulling location
payment (self "https://pay.example.com") Restrict Payment API to your own domain and payment processor
usb / bluetooth / serial () Completely block WebUSB-style device access
interest-cohort () Historical: opt out of Google's FLoC

The idea is to default to a conservative setting: "don't let anyone use powerful APIs your site never intends to". Even if a third-party ad / analytics tag calls navigator.mediaDevices.getUserMedia(), the browser immediately rejects it if Permissions-Policy says camera=().

07

Cross-Origin Isolation — the post-Spectre world (COOP / COEP / CORP) #

After the 2018 Spectre / Meltdown vulnerabilities, browsers were rewritten on the assumption that "other origins' memory inside the same process cannot be trusted". High-precision timers and shared memory like SharedArrayBuffer became usable only when the browser could isolate the origin into a separate process (cross-origin isolated). The triumvirate that declares this is COOP / COEP / CORP.

Header Meaning Typical value
COOP (Cross-Origin-Opener-Policy) Prevent cross-origin windows from peeking via window.opener same-origin
COEP (Cross-Origin-Embedder-Policy) Force CORP / CORS consent on sub-resources embedded in your page require-corp
CORP (Cross-Origin-Resource-Policy) Sub-resources declare "who may embed me" same-origin / same-site / cross-origin
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
▸ Plain version — "limit who shares the room"

Older browsers ran multiple origins in a single process (to save memory). Spectre is the attack that peeks at that shared memory. The countermeasure is to ask the browser "please isolate our origin into a separate room (process)". With COOP / COEP / CORP all set, crossOriginIsolated === true becomes true, the browser runs the page in a separate process, and high-precision timers like SharedArrayBuffer and performance.now() become available.

Practically, even sites that don't use SharedArrayBuffer still get value from COOP: same-origin alone — it blocks cross-origin control via window.opener ("tabnabbing"), which matters for sites that use target="_blank" heavily. COEP requires fixing all your require-corp compatibility for any externally loaded CDN, which has substantial introduction cost. If you need OAuth popups, set COOP to same-origin-allow-popups as a compromise that allows popup communication.

08

Verification and operations — what's set, what's actually effective #

Headers you think you set but that aren't actually effective produce the worst-case outcome under attack: "the wall you thought was there is missing". After deployment, verify from outside.

Read raw headers with curl #

curl -sI to grab only response headers
# -s: suppress progress / -I: HEAD request $ curl -sI https://example.com/ | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer|permissions|cross-origin' strict-transport-security: max-age=63072000; includeSubDomains; preload content-security-policy: default-src 'self'; script-src 'self' 'nonce-...'; ... x-frame-options: DENY x-content-type-options: nosniff referrer-policy: strict-origin-when-cross-origin permissions-policy: camera=(), microphone=(), geolocation=(self) cross-origin-opener-policy: same-origin

Score with external scanners #

securityheaders.com (run by Scott Helme) is a free lightweight checker that grades A+ through F. Mozilla Observatory (observatory.mozilla.org) goes deeper, scoring overall including TLS settings and Cookie attributes. Aim for A or higher on both.

nginx implementation example #

nginx.conf — inside a server block
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; add_header X-Frame-Options "SAMEORIGIN" 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=(self)" always; add_header Cross-Origin-Opener-Policy "same-origin" always; # CSP needs nonce — building it in the app and returning it is more practical

Forgetting the trailing always means headers are not added on error pages (4xx / 5xx). Most attacker-triggered responses are errors, so always is mandatory.

Laravel implementation example #

Attach via middleware. Generate a nonce per request and embed it in CSP.

app/Http/Middleware/SecurityHeaders.php
// attach security headers to every response public function handle($request, Closure $next) { $nonce = base64_encode(random_bytes(16)); view()->share('cspNonce', $nonce); // reference as {{ $cspNonce }} in blade

$response = $next($request); $response->headers->set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload'); $response->headers->set('X-Content-Type-Options', 'nosniff'); $response->headers->set('X-Frame-Options', 'SAMEORIGIN'); $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); $response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'self' 'nonce-{$nonce}'; " . "style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; " . "object-src 'none'; base-uri 'self'; frame-ancestors 'self'"); return $response; }

A realistic deployment order #

1. Start with the light four
X-Content-Type-Options / X-Frame-Options / Referrer-Policy / HSTS (start with short max-age). Defensive layers increase without changing app behavior.
2. Ship CSP in Report-Only
Use Content-Security-Policy-Report-Only to collect violations only. Inventory all inline / external script dependencies and chart a path to drop `'unsafe-inline'`.
3. Switch CSP to enforce
Promote to Content-Security-Policy. Move to nonce-based. Keep Report-Only running in parallel to keep catching violations under the new policy.
4. Apply for HSTS preload
Run max-age=31536000; includeSubDomains; preload stably for a year, then register at `hstspreload.org`. Become a "hard-coded one".
5. Permissions-Policy / COOP-COEP-CORP
Audit feature dependencies and third-party embeds, then add incrementally. Finally, if you want cross-origin isolation (needed for SharedArrayBuffer), consolidate to CORP-compatible resources only.
Finally — headers alone don't prevent XSS

CSP is "defense that limits damage after XSS has already happened". It is not "CSP becomes unnecessary if output escaping is correct"; rather, CSP is insurance for the one place where output escaping is missed. The reverse — "we have CSP so skip output escaping" — is upside-down. True safety only comes in the order correctness in the app layer → header defense in the browser layer.