# Security Headers: CSP, HSTS, and Friends

> The HTTP response headers that harden a website - Content-Security-Policy, HSTS, and the rest - explained by the exact attack each one stops.


---

# Security Headers: CSP, HSTS, and Friends

You shipped the site. Auth works, the database is locked down, you ran a scanner - and it still flags a list of "missing security headers" you've never heard of. It feels like busywork, a checklist someone invented to make you feel bad. It isn't. These headers are some of the cheapest, highest-leverage defense you will ever add: a few lines of config that quietly close off whole categories of attack.

This guide walks you through what each header actually does and the specific attack it stops - so you stop copy-pasting a magic block from a blog post and start understanding what you're turning on. By the end you'll be able to roll out the scariest one, Content-Security-Policy, without taking your own site down.

## How to read this

- **Want the mental model first?** Read in order. Phase 1 reframes headers as a layered fence, Phase 2 walks the everyday set you'll set on nearly every site, and Phase 3 is the careful rollout of CSP - the one that breaks things if you rush it.
- **Already shipping and need the dangerous one done right?** Go straight to [Phase 3: Rolling Out CSP Without Breaking the Site](03-rolling-out-csp.md) - but skim Phase 2 first so the cookie flags and clickjacking defenses are in place.

## The phases

1. **[Headers Are a Fence, Not a Lock](01-headers-are-a-fence.md)** - why response headers are cheap, high-leverage defense, how the browser is the thing that enforces them, and what they can and can't protect.
2. **[The Everyday Hardening Set](02-the-everyday-set.md)** - HSTS, X-Content-Type-Options, X-Frame-Options / frame-ancestors, Referrer-Policy, and the cookie flags (HttpOnly, Secure, SameSite), each tied to the attack it stops.
3. **[Rolling Out CSP Without Breaking the Site](03-rolling-out-csp.md)** - Content-Security-Policy from scratch: what it blunts, why report-only comes first, reading the violation reports, and tightening the policy until it's tight but not broken.


---

# Headers Are a Fence, Not a Lock

Here's the thing nobody tells you up front: a security header is not something *your server* enforces. It's an instruction your server hands to the *browser*, and the browser does the enforcing. You're not building a wall around your code - you're whispering to every visitor's browser, *"by the way, refuse to do these dangerous things while you're on my site."* Once that clicks, the whole topic stops feeling like arbitrary checklist items and starts feeling like what it is: a set of opt-in safety rules you switch on.

## What a security header actually is

A security header is a line in your HTTP *response* - the same response that carries your HTML. The browser reads it before it does anything risky.

```console
$ curl -I https://example.com
HTTP/2 200
content-type: text/html; charset=utf-8
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
content-security-policy: default-src 'self'
referrer-policy: strict-origin-when-cross-origin
```
*What just happened:* `curl -I` asked only for the response headers (no body). Everything after `content-type` is a security header - each one a rule the server is asking the browser to follow. The server isn't *blocking* anything here; it's delegating enforcement to whatever browser loads the page.

That delegation is the whole mental model. The browser is already the most security-aware program on a user's machine - it sandboxes tabs, isolates origins, manages cookies. Security headers tap into that existing machinery and tell it, *"turn the dial up for this site."*

## Why this is the cheapest defense you have

Most security work is expensive: auditing code, rotating secrets, patching dependencies, writing tests for attack paths. Headers are different. They're a few lines of config, set once, applied to every response - and they defend against attacks you can't fully prevent in code.

That last part matters. You will never write a web app with zero bugs. Some day a piece of user input will slip through and end up in your HTML unescaped. That's a cross-site scripting (XSS) bug, and it's the kind of mistake every team makes eventually. A good Content-Security-Policy means that even when that bug exists, the injected script may not be allowed to *run*. The header doesn't fix the bug - it contains the blast radius.

💡 **This is defense in depth.** You don't pick *either* clean code *or* headers. You layer them. The code is the lock on the door; the headers are the fence around the yard. When the lock fails - and locks fail - the fence is still standing.

## What headers can and can't do

Be clear-eyed with yourself about the limits, because over-trusting a header is its own kind of bug.

```text
  Security header says:  "Browser, don't do X."
                          │
            ┌─────────────┴─────────────┐
            ▼                            ▼
  A real browser obeys.        curl / a script / an
  Your users are protected.    attacker's own tool
                               doesn't care at all.
```
*What just happened:* the diagram splits the world in two. Headers protect *people using browsers* - which is most of your users, most of the time. They do nothing against a tool that simply ignores them. An attacker hitting your API directly with a script never sees your CSP and doesn't care.

So headers are not a substitute for server-side checks. They don't replace authentication, input validation, or authorization. If an endpoint shouldn't be public, headers won't hide it. What headers *do* is protect your legitimate users from a hostile page, a hijacked dependency, or your own injection bug - the attacks that happen *inside* the victim's browser.

⚠️ **Don't let a green scanner score lull you.** Passing a "security headers" grading site means your fence is up. It says nothing about the lock on the door. Plenty of A+ header scores sit in front of wide-open auth bugs. Headers are *a* layer, not *the* layer.

## Where they fit in the bigger picture

Two of these headers lean directly on transport security and origin rules you may already know. HSTS only makes sense once you understand HTTPS - it's the header that forces it - so the connection layer in [HTTPS & TLS](/guides/https-and-tls) is the ground this stands on. And several headers (CSP's `connect-src`, the cookie `SameSite` flag) overlap with the cross-origin rules covered in [CORS, Explained](/guides/cors-explained). They're cousins: CORS governs which origins may *read* your responses; these headers govern what a page may *do* and *load*.

If you want the bird's-eye view of which attacks matter most, [OWASP Top 10](/guides/owasp-top-10) is the standard list - and you'll notice headers in this guide map straight onto several entries on it (injection, security misconfiguration, broken access control's cousins).

**For builders:** set headers in one place, not per-route. A single middleware or a reverse-proxy (nginx, Caddy) block that stamps every response is far easier to reason about - and audit - than headers scattered across handlers. Centralize, then you only have one thing to get right.

## Recap

1. A security header is an instruction in your HTTP *response* that the **browser** enforces on your behalf - you delegate, the browser does the work.
2. They're the **cheapest high-leverage defense** available: a few lines of config that close off whole attack categories and contain the damage when your code has a bug.
3. They are **defense in depth** - a fence around the lock, not a replacement for it.
4. They protect **people in browsers**, not against scripts and tools that ignore them - so never let a header stand in for real server-side auth and validation.

```quiz
[
  {
    "q": "Who actually enforces a security header like Content-Security-Policy?",
    "choices": ["Your web server, before sending the response", "The browser that receives the response", "The operating system's firewall", "The DNS resolver"],
    "answer": 1,
    "explain": "A security header is an instruction in the response; the browser reads it and enforces the rule. The server only delegates."
  },
  {
    "q": "An attacker hits your API directly with a custom script, ignoring your CSP and other headers. What protects you there?",
    "choices": ["The CSP header still blocks them", "Nothing in this guide - headers protect browsers, so you need server-side auth and validation", "X-Frame-Options stops the script", "HSTS forces the script to obey"],
    "answer": 1,
    "explain": "Headers only affect real browsers. Scripts and tools ignore them, so server-side checks remain essential."
  },
  {
    "q": "Why are security headers called 'defense in depth' rather than a primary fix?",
    "choices": ["They are the only defense a site needs", "They replace authentication entirely", "They add a layer that contains damage when your code has a bug, without fixing the bug", "They make the server faster"],
    "answer": 2,
    "explain": "Headers don't fix bugs like XSS; they limit the blast radius when a bug slips through - a layer on top of the code's own defenses."
  }
]
```


---

# The Everyday Hardening Set

This is the set you reach for on almost every site - headers that aren't dramatic to configure but each shut a specific door. Same format each time: the attack it stops, then the line you set. No magic blocks - by the end you'll know exactly what each one defends against and why it's there.

## HSTS: force HTTPS, stop the downgrade

You redirect HTTP to HTTPS, so you might think you're done. You're not. The danger lives in that *very first* request, before the redirect happens.

Picture a user on coffee-shop Wi-Fi typing `example.com`. The browser's first guess is `http://example.com`. That plaintext request crosses the network, and an attacker on the same Wi-Fi intercepts it before your redirect ever runs - a **downgrade** (or SSL-stripping) attack. They keep the user on `http` and quietly read everything.

`Strict-Transport-Security` (HSTS) closes that window. It tells the browser: *"for the next N seconds, never even attempt HTTP for this site - go straight to HTTPS, no plaintext request at all."*

```text
strict-transport-security: max-age=31536000; includeSubDomains; preload
```
*What just happened:* `max-age=31536000` is one year in seconds - the browser remembers this rule for a year. `includeSubDomains` extends it to every subdomain. `preload` is your opt-in to a list browsers ship *built in*, so the protection applies on the very first visit, before the user has ever seen your site. After the first successful HTTPS visit, the browser refuses to talk HTTP to you, and the downgrade attack has nothing to grab.

⚠️ **HSTS is a commitment, not a toggle.** Once a browser has seen `max-age=31536000`, it will refuse plaintext HTTP to your domain for a year - you cannot take that back by removing the header. If your TLS certificate lapses or a subdomain genuinely needs HTTP, users are locked out with no override. Start with a small `max-age` (a few minutes), confirm everything works on HTTPS, then ramp up. Only add `preload` once you're certain - getting *off* the preload list is slow.

For the transport mechanics underneath all of this, see [HTTPS & TLS](/guides/https-and-tls). HSTS is the header that makes that encryption non-optional.

## X-Content-Type-Options: stop MIME sniffing

Browsers used to be "helpful": if a server labeled a file `text/plain` but the bytes looked like HTML or JavaScript, the browser would second-guess the label and run it as code. That "helpfulness" is an attack vector. If your site lets users upload a file and you serve it back, an attacker can upload something you *think* is a harmless text file but the browser decides to execute.

```text
x-content-type-options: nosniff
```
*What just happened:* `nosniff` tells the browser to *trust the `Content-Type` you sent* and never guess. A file labeled `text/plain` is treated as plain text, full stop - even if its contents look like a script. One value, one line, no downside. Set it everywhere.

## X-Frame-Options and frame-ancestors: stop clickjacking

**Clickjacking** is sneaky: an attacker loads your real site inside an invisible `<iframe>` on *their* page, then lays their own buttons over the top. The user thinks they're clicking "Watch video" on the attacker's page - but their click lands on your "Delete account" or "Transfer money" button underneath. Your site rendered perfectly; the user was tricked into clicking it.

The defense is to forbid your pages from being framed by other sites. There are two headers for this, old and new:

```text
x-frame-options: DENY
content-security-policy: frame-ancestors 'none'
```
*What just happened:* `X-Frame-Options: DENY` is the older header - it says *"never let any site put me in a frame."* The CSP directive `frame-ancestors 'none'` is the modern equivalent and it's more flexible: you can write `frame-ancestors 'self'` to allow your own pages to frame each other, or list specific trusted origins. When both are present, browsers honor `frame-ancestors`, so it's the one that matters going forward - but `X-Frame-Options` is harmless to keep for older clients.

💡 **`frame-ancestors` lives inside Content-Security-Policy**, the big header covered in Phase 3. You can set it on its own today as a standalone CSP - it rarely breaks anything, making it one of the safest CSP directives to start with.

## Referrer-Policy: stop leaking URLs

When a user clicks a link from your site to another, the browser tells the destination where they came from - the `Referer` header. If your URLs contain anything sensitive - a password-reset token, a session id, an internal path - you've handed it to whatever site they clicked through to.

```text
referrer-policy: strict-origin-when-cross-origin
```
*What just happened:* this policy sends the *full* URL only when staying on your own origin, sends just the **origin** (scheme + host, no path or query) when crossing to another HTTPS site, and sends **nothing** when downgrading to HTTP. The sensitive part of the URL - the path and query string - never leaves your site. This is also the modern browser default, but setting it explicitly means you're not relying on a default that could change.

## Cookie flags: protect the session

Cookies aren't a header you set once globally - each flag rides along on the `Set-Cookie` for *each* cookie. But they belong here because the session cookie is the crown jewel: steal it, and an attacker *is* the user. Three flags guard it.

```text
set-cookie: session=abc123; HttpOnly; Secure; SameSite=Lax
```
*What just happened:* three separate protections on one cookie:

- **`HttpOnly`** - JavaScript cannot read this cookie via `document.cookie`. This is the direct counter to XSS stealing your session: even if an attacker runs a script on your page, the session cookie is invisible to it.
- **`Secure`** - the browser only sends this cookie over HTTPS, never plaintext HTTP. Pairs with HSTS to keep the session off the wire in the clear.
- **`SameSite=Lax`** - the browser won't attach this cookie to most cross-site requests (like a form auto-submitted from a malicious page), which blunts **CSRF** (cross-site request forgery). `Lax` still sends the cookie on top-level navigations so normal links from other sites keep working; `Strict` is tighter but can log users out when they arrive via an external link.

⚠️ **Set all three on every session/auth cookie.** Missing `HttpOnly` turns any XSS bug into a session theft. Missing `Secure` lets the cookie leak over HTTP. Missing `SameSite` leaves the door open for CSRF. These three are non-negotiable on anything that authenticates a user.

**For builders:** put HSTS, `nosniff`, `frame-ancestors`, and `Referrer-Policy` in one shared middleware or proxy block so every response gets them automatically. Cookie flags are the exception - set them where you create the cookie, and audit your auth code specifically to confirm all three are present.

## Recap

| Header | Attack it stops |
|---|---|
| `Strict-Transport-Security` | Downgrade / SSL-stripping - forces HTTPS, even the first request |
| `X-Content-Type-Options: nosniff` | MIME sniffing - browser runs a mislabeled file as code |
| `X-Frame-Options` / `frame-ancestors` | Clickjacking - your site framed invisibly under fake buttons |
| `Referrer-Policy` | URL leakage - sensitive paths/tokens handed to other sites |
| Cookie `HttpOnly` / `Secure` / `SameSite` | Session theft via XSS, plaintext leak, and CSRF |

```quiz
[
  {
    "q": "Why isn't an HTTP→HTTPS redirect enough on its own, and what does HSTS add?",
    "choices": ["Redirects are slow; HSTS caches the page", "The first plaintext request can be intercepted before the redirect runs; HSTS makes the browser skip HTTP entirely", "HSTS encrypts the cookies", "Redirects only work on mobile; HSTS covers desktop"],
    "answer": 1,
    "explain": "The vulnerable moment is the initial http request. HSTS tells the browser never to attempt HTTP for the domain, closing that window."
  },
  {
    "q": "Which cookie flag specifically stops a successful XSS attack from reading the session cookie?",
    "choices": ["Secure", "SameSite=Lax", "HttpOnly", "Path=/"],
    "answer": 2,
    "explain": "HttpOnly hides the cookie from document.cookie, so JavaScript injected via XSS can't read it. Secure and SameSite address different threats."
  },
  {
    "q": "An attacker loads your real site in an invisible iframe and overlays fake buttons so users click your real ones. What is this, and which directive stops it?",
    "choices": ["MIME sniffing - stopped by nosniff", "Clickjacking - stopped by frame-ancestors / X-Frame-Options", "CSRF - stopped by Referrer-Policy", "Downgrade - stopped by HSTS"],
    "answer": 1,
    "explain": "That's clickjacking. Forbidding framing with frame-ancestors (or X-Frame-Options) stops your pages from being embedded by other sites."
  }
]
```


---

# Rolling Out CSP Without Breaking the Site

Content-Security-Policy is the most powerful header in this guide and the one most likely to break your own site if you slap it on blind. That's why it gets its own phase. The payoff is worth the care: a good CSP means that even when an XSS bug slips into your code, the injected script may never be allowed to run. We'll build the policy from a mental model, deploy it in a mode that *can't* break anything, read what it reports, and only then turn on enforcement.

## What CSP actually does

Reframe it like this: by default a browser will load a script, a stylesheet, an image, a font from *anywhere* a page tells it to. CSP flips that to **deny-by-default** and makes you list the sources you actually trust.

So when an attacker injects `<script src="https://evil.example/steal.js">` through an XSS bug, the browser checks your policy, sees `evil.example` isn't on the allow-list, and refuses to load it. The bug is still in your code - but the exploit can't pull off the part that does the damage. CSP doesn't prevent injection; it strips injection of its power.

```text
content-security-policy: default-src 'self'; script-src 'self' https://cdn.example.com; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'
```
*What just happened:* read it as a list of rules separated by `;`. `default-src 'self'` is the fallback: load everything only from your own origin. Then specific overrides - scripts may also come from `cdn.example.com`, images may also be inline `data:` URIs, `object-src 'none'` kills legacy plugins entirely, and `frame-ancestors 'none'` is the clickjacking defense from Phase 2 living in its natural home. Anything not listed is blocked.

💡 **The directive you care about most is `script-src`.** That's the one standing between an XSS injection and a running exploit. `default-src` is the safety net for everything you didn't name explicitly.

## Why `'unsafe-inline'` defeats the point

Here's the trap. Most real sites have inline scripts - `<script>doStuff()</script>` right in the HTML, or `onclick="..."` attributes. A strict CSP blocks those, your site breaks, and the tempting fix is to add `'unsafe-inline'` to `script-src`.

⚠️ **`'unsafe-inline'` undoes most of CSP's XSS protection.** XSS *is* injected inline script. Allowing all inline script allows the attacker's inline script too. You've kept the header and thrown away the protection. If you only do one thing right with CSP, it's this: don't reach for `'unsafe-inline'` to make the errors go away.

The real fix is a **nonce** - a random value you generate per request, put on your legitimate inline scripts, and name in the header. The attacker, injecting blind, can't guess it.

```text
content-security-policy: script-src 'self' 'nonce-r4nd0mPerRequest'
```
```html
<script nonce="r4nd0mPerRequest">doStuff()</script>   <!-- runs: nonce matches -->
<script>stealCookies()</script>                        <!-- blocked: no nonce -->
```
*What just happened:* the browser runs only inline scripts whose `nonce` attribute matches the one in the header. Your own scripts get the per-request value stamped on them by your server; an injected script can't, because the attacker never sees that request's random value. Inline scripts work, XSS injection still dies.

## The safe way to deploy: report-only first

You will not get the policy right on the first try. Real sites pull from analytics scripts, embedded fonts, a CDN, a payment widget - and you'll forget half of them. Turn on enforcement blind and you'll black out parts of your own site for real users.

So you start with the twin header that **reports but never blocks**:

```text
content-security-policy-report-only: default-src 'self'; report-uri /csp-reports
```
*What just happened:* `Content-Security-Policy-Report-Only` runs your policy in observe mode. Nothing is blocked - the page works exactly as before - but every time something *would* have been blocked, the browser POSTs a JSON report to the `report-uri` you named. You collect those reports, see every legitimate source you forgot, and add them. You're tuning the policy against real traffic with zero risk to users.

```mermaid
flowchart LR
  A[Set Report-Only header] --> B[Browsers send violation reports]
  B --> C[Read reports: which real sources got flagged?]
  C --> D{Only legit sources left?}
  D -->|No| E[Add the legit source to the policy]
  E --> B
  D -->|Yes| F[Switch header to enforcing CSP]
```

A violation report looks roughly like this:

```text
{
  "csp-report": {
    "document-uri": "https://example.com/checkout",
    "violated-directive": "script-src",
    "blocked-uri": "https://analytics.example.net/tag.js"
  }
}
```
*What just happened:* the browser is telling you that on `/checkout`, your `script-src` would have blocked the analytics tag from `analytics.example.net`. That's a *legitimate* source you forgot - so you add `https://analytics.example.net` to `script-src`. Do this until the only violations left are things you actually *want* blocked (or none at all). Then, and only then, rename the header from `...-Report-Only` to `Content-Security-Policy` and it starts enforcing.

💡 **Keep `report-uri` even after you enforce.** Once live, those reports become an alarm: a sudden spike in violations can be the first sign of an attempted XSS attack hitting your policy - exactly the thing CSP exists to stop, now telling you it's working.

## A realistic rollout order

```text
1. Ship  frame-ancestors 'none'  alone        → safe, instant clickjacking win
2. Add   Content-Security-Policy-Report-Only   → observe, break nothing
3. Read reports for a week, add real sources   → tune against real traffic
4. Replace inline scripts with nonces          → so you never need 'unsafe-inline'
5. Flip Report-Only → enforcing                → protection is now live
6. Keep report-uri on                          → ongoing alarm
```
*What just happened:* this sequence gets you a hard clickjacking win on day one, then lets you build the script policy gradually with a safety net the whole way. The order matters - flipping to enforcing (step 5) is the last thing you do, after the reports have gone quiet.

CSP is one of the controls behind the injection and misconfiguration entries on the [OWASP Top 10](/guides/owasp-top-10), and its `connect-src` directive interacts with the cross-origin rules in [CORS, Explained](/guides/cors-explained) - CSP decides which origins your page may *connect to*, CORS decides which origins may *read your responses*.

**For builders:** generate the nonce in the same middleware that sets the header, expose it to your templating layer, and stamp it on every inline `<script>` you control. One source of truth for the nonce per request - don't hand-roll it in two places.

## Recap

1. CSP flips loading to **deny-by-default** and makes you allow-list trusted sources; an injected script from an unlisted origin won't run.
2. **`'unsafe-inline'` throws away most of the XSS protection** - use a per-request **nonce** for your legitimate inline scripts instead.
3. **Deploy with `Content-Security-Policy-Report-Only` first** so nothing breaks while you collect violation reports and discover the sources you forgot.
4. Tune until only unwanted things are flagged, **then flip to enforcing** - and keep `report-uri` on as a live alarm.

```quiz
[
  {
    "q": "Why does adding 'unsafe-inline' to script-src undermine CSP's main benefit?",
    "choices": ["It slows the page down", "XSS is injected inline script, so allowing all inline script allows the attacker's too", "It disables HSTS", "It only works in old browsers"],
    "answer": 1,
    "explain": "CSP's core XSS defense is blocking injected inline script. 'unsafe-inline' re-permits exactly that, including the attacker's payload."
  },
  {
    "q": "What does Content-Security-Policy-Report-Only do that the enforcing header does not?",
    "choices": ["It blocks more aggressively", "It encrypts the response", "It reports what would be blocked without actually blocking anything", "It only applies to images"],
    "answer": 2,
    "explain": "Report-Only runs the policy in observe mode: nothing is blocked, but violations are reported, so you can tune the policy safely before enforcing."
  },
  {
    "q": "How does a nonce let your legitimate inline scripts run while still blocking injected ones?",
    "choices": ["It encrypts each script", "Your server stamps a per-request random value on real scripts and names it in the header; an injected script can't guess it", "It allows all scripts from your origin", "It disables script-src for inline code"],
    "answer": 1,
    "explain": "Only inline scripts whose nonce matches the header value run. The attacker, injecting blind, never sees that request's random nonce."
  }
]
```
