Skip to content
How systems talk

5Station 5 of 6: Browser

Chapter 07 · 28 minutes

The response that trusted the browser's defaults

Without security headers, browsers load your site over HTTP after one HTTPS visit, run inline scripts, guess content types, leak full URLs to other sites, and cache sensitive pages.

From the lab folder:
$ uv run python -m wire run 7
$ uv run python -m wire check 7

Goal#

Fix a web application that sets none of the five essential security headers, leaving users exposed to protocol downgrade, XSS, content sniffing, referrer leaks, and cache poisoning.

The idea#

What HTTP security headers do

HTTP responses include headers that tell the browser how to handle the content. Five headers control security behavior that the browser will not do correctly by default.

Without these headers, the browser falls back to defaults that were designed in the 1990s for compatibility, not security. The threat model was "help the document render", not "stop it from stealing credentials". Modern attacks exploit these defaults.

The five headers:

  1. Strict-Transport-Security (HSTS)
  2. Content-Security-Policy (CSP)
  3. X-Content-Type-Options
  4. Referrer-Policy
  5. Cache-Control

If you do not set them, you do not have them. The browser does not add them for you.

Why each header matters

Strict-Transport-Security (HSTS). Tells the browser to only ever load this site over HTTPS, even if the user types http:// or clicks a plain HTTP link. Without it, the first request after typing the domain goes over HTTP, and an attacker on the network can intercept it, steal the session cookie, and redirect to a phishing site. With HSTS, the browser refuses to make the HTTP request at all. Format: Strict-Transport-Security: max-age=31536000; includeSubDomains. The max-age is in seconds (31536000 is one year). Once set, the browser remembers the policy for that duration.

Content-Security-Policy (CSP). Tells the browser which scripts, styles, images, and frames are allowed to run. Without it, any injected <script> tag runs immediately, and XSS becomes trivial. With a strict CSP, only scripts from allowed origins run, and inline scripts are blocked unless you use nonces or hashes. A minimal CSP: Content-Security-Policy: default-src 'self'; frame-ancestors 'none'. This allows resources only from the same origin and prevents the page from being framed (clickjacking protection).

X-Content-Type-Options: nosniff. Tells the browser not to guess the content type. Without it, the browser interprets uploaded JSON as HTML if it looks like HTML, turning a data leak into script execution. With nosniff, the browser trusts the Content-Type header and nothing else. An attacker who uploads {"<script>alert(1)</script>": true} as JSON cannot make the browser execute it.

Referrer-Policy. Tells the browser how much of the URL to send in the Referer header when the user follows a link. Without it, every external link leaks the full URL, including query parameters, which often contain tokens or session ids. A good policy: Referrer-Policy: strict-origin-when-cross-origin. This sends the full URL for same-origin requests (so your analytics work) and only the origin for cross-origin requests (so tokens are not leaked).

Cache-Control. Tells the browser and proxies whether to cache the response. Without it, the browser caches everything it can. For account pages, checkout pages, and API responses with personal data, use Cache-Control: no-store. This prevents the response from being cached anywhere. For public pages, use Cache-Control: public, max-age=N where N is how long the content stays fresh.

How a request without headers is handled

A user visits /account. The server sends back HTML with no security headers. The browser:

  1. Accepts the page over HTTP if that is what the user typed, even if HTTPS is available. An attacker intercepts the session cookie.
  2. Runs every script on the page, including an injected <script src="https://evil.test/steal.js"></script>. The script reads document.cookie and sends it to the attacker.
  3. Guesses the content type of an uploaded file. The attacker uploads a JSON file with a <script> tag, and the browser executes it.
  4. Sends the full URL in the Referer header when the user clicks a link. The URL contains ?reset_token=abc123, and the token leaks to the external site.
  5. Caches the account page in the browser cache or a shared proxy. The next user on that device or network sees the previous user's account page.

With the five headers, all of these attacks are blocked.

What people get wrong

Adding headers only to successful responses. Security headers belong on every response, including 404, 500, and redirects. An attacker who triggers an error page can still exploit missing headers. The fix is to add headers in middleware or a global handler that wraps every response.

Setting CSP but allowing 'unsafe-inline'. A CSP with script-src 'self' 'unsafe-inline' allows inline scripts, which defeats the purpose. Any XSS vulnerability can inject inline scripts. The fix is to use nonces or hashes, or load scripts from files only.

Using Cache-Control: no-cache instead of no-store. no-cache allows caching but requires revalidation. The response is still stored. no-store prevents caching entirely. For sensitive pages, use no-store.

How production systems use security headers

Frameworks and middleware. Django has SECURE_HSTS_SECONDS, SECURE_CONTENT_TYPE_NOSNIFF, and CSP settings. Rails has config.force_ssl, which sets HSTS. Express has helmet.js, which sets HSTS, CSP, X-Content-Type-Options, and Referrer-Policy (but not Cache-Control, so the account page still needs that set by the app). ASP.NET Core has UseHsts() built in; CSP comes from a library such as NWebsec (its UseCsp() middleware) or custom middleware. Use your framework's built-in support rather than setting headers manually on every route.

HSTS preload list. Browsers ship with a list of domains that are always loaded over HTTPS, even on the very first visit. Submitting to the preload list (at hstspreload.org) is a one-way commitment: removing your domain later is slow and not guaranteed. Use it only if you are certain your entire site and all subdomains will always be on HTTPS. Requires Strict-Transport-Security: max-age=31536000; includeSubDomains; preload.

CSP reporting. CSP can operate in report-only mode: it logs violations but does not block them. Set Content-Security-Policy-Report-Only with a report-uri or report-to directive, deploy it, and watch what breaks. Once you have fixed the violations, switch to enforcing mode with Content-Security-Policy. Many sites never leave report-only mode because their CSP is too loose to enforce.

Security scanners. Tools like Mozilla Observatory, SecurityHeaders.com, and Lighthouse (in Chrome DevTools) check for missing headers. Run them against your site in staging and production. Fix every missing or misconfigured header before you deploy.

CDNs and load balancers. Cloudflare, Fastly, AWS CloudFront, and Akamai can add security headers at the edge. This is useful for static sites or APIs where you cannot easily change the origin server. Configure the headers in the CDN control panel or via Terraform/CloudFormation. Do not rely on the origin and the CDN both setting headers; the result is duplicate headers, which some browsers reject.

The case#

Run the case to see the missing headers:

shell
uv run python -m wire run 7
output
case 07: The response that trusted the browser's defaults
GET /         0 of 5 security headers
GET /account  0 of 5 security headers
GET /missing  0 of 5 security headers
  strict-transport-security  only ever load this site over HTTPS
  content-security-policy    only run scripts and frames we allow
  x-content-type-options     never guess a content type
  referrer-policy            do not leak full URLs to other sites
  cache-control              do not keep the account page in caches

Every response, including the account page, sets zero of the five essential headers.

Reasoning towards the fix#

Open lab/cases/case07_headers.py. The app function handles three routes: /, /account, and a 404 for everything else. Every response calls security_headers(request.path) and extends response.headers with the result.

As written, security_headers returns an empty list. No headers are added.

To fix it, return the five headers from security_headers:

python
def security_headers(path: str) -> list[tuple[str, str]]:
    """Headers added to every response, including errors."""
    headers = [
        ("Strict-Transport-Security", "max-age=31536000; includeSubDomains"),
        ("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'; base-uri 'none'"),
        ("X-Content-Type-Options", "nosniff"),
        ("Referrer-Policy", "strict-origin-when-cross-origin"),
    ]
    if path.startswith("/account"):
        headers.append(("Cache-Control", "no-store"))
    return headers

The Strict-Transport-Security header with max-age=31536000 tells the browser to remember the HTTPS-only rule for one year. includeSubDomains applies it to every subdomain.

The Content-Security-Policy header with default-src 'self' allows resources only from the same origin. frame-ancestors 'none' prevents the page from being framed by any site, stopping clickjacking. base-uri 'none' prevents <base> tag injection.

The X-Content-Type-Options: nosniff header stops the browser from guessing content types. If the Content-Type is application/json, the browser will not try to render it as HTML.

The Referrer-Policy: strict-origin-when-cross-origin header sends the origin only (no path) on cross-origin requests, and the full URL on same-origin requests.

For /account, add Cache-Control: no-store to prevent caching entirely. Personal data must not sit in a shared cache or the browser cache.

The checks verify that all five headers are present on every response, including 404s, and that the account page uses no-store.

Check your fix#

Once you have made the changes, check them:

shell
uv run python -m wire check 7

As shipped, the check output is:

output
  FAIL  HSTS for at least a year, on every response
  FAIL  a Content-Security-Policy that stops framing, on every response
  FAIL  X-Content-Type-Options: nosniff everywhere
  FAIL  a Referrer-Policy that keeps paths on the site
  FAIL  the account page is never stored in a cache
not yet. Try: uv run python -m wire hint 7

When fixed, all lines show PASS. Every response includes all five headers, and the account page uses Cache-Control: no-store.

Try it on your own machine#

Check your own apps. Open the browser's developer tools, go to the Network tab, and inspect the response headers for any request. Look for the five headers.

shell
curl -I https://yourapp.example/

If any are missing, add them.

In production#

Set these five headers on every response, in every environment. Production, staging, and preview environments all need them.

  • Strict-Transport-Security: start with max-age=300 for 5 minutes while you test. Once you are confident HTTPS is working everywhere, increase to max-age=31536000 (one year) and add includeSubDomains. If you want preload (browsers ship with your domain in the HSTS preload list), add preload and submit to hstspreload.org, but understand that it is a one-way commitment: you cannot easily undo it.

  • Content-Security-Policy: start with default-src 'self' to allow same-origin resources only. For inline scripts, use nonces or hashes instead of 'unsafe-inline'. For third-party scripts (analytics, ads), list them explicitly. Use a CSP report endpoint to see what you are blocking before you enforce it.

  • X-Content-Type-Options: nosniff: always set this. There is no downside.

  • Referrer-Policy: use strict-origin-when-cross-origin for most apps. This sends the origin on cross-origin requests and the full URL on same-origin requests. If you never want to send the referrer at all, use no-referrer, but understand that this breaks some analytics and referral tracking.

  • Cache-Control: for sensitive pages (account, checkout, admin), use no-store. For public pages, use public, max-age=N where N is how long the content stays fresh. For API responses, use no-cache to allow caching but require revalidation.

Deploy these headers at the framework level, not per-route. Middleware or a decorator ensures every response gets them, including 404s and error pages. Missing headers on an error page is still an exposure.

Test in staging first. CSP in particular will block things you did not expect, like browser extensions and third-party embeds. Use CSP reporting to find what breaks before you enforce.

Key points#

  • Five headers are not optional: Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options, Referrer-Policy, Cache-Control.
  • Browsers default to allowing everything. If you do not set these headers, you have no protection.
  • Set them on every response, including error pages, using middleware or a global hook.
  • For sensitive pages, use Cache-Control: no-store to prevent caching.
  • Start with restrictive policies and relax them only when necessary. Do not start permissive and try to tighten later.