Default-Deny Instructions for the Browser#

HTTP response headers are a cheap layer of security that the browser enforces on your behalf. You set them once in a web server, framework or CDN config, and every page load afterwards carries instructions like "never load this site over plain HTTP", "do not let other sites frame this page" and "only run scripts I signed". A correct header set shuts down whole attack classes (clickjacking, protocol downgrade, MIME confusion, much of XSS), and checking it is the first thing any manual review does because it takes seconds.

This lesson covers the headers a modern site should send, what each one actually stops, how to deploy them without breaking your own pages, and how to verify them.

The Core Set#

Header Recommended value Stops
Content-Security-Policy nonce-based script-src, object-src 'none', base-uri 'none', frame-ancestors 'none' Most injected-script XSS, clickjacking
Strict-Transport-Security max-age=63072000; includeSubDomains SSL stripping, accidental HTTP
X-Content-Type-Options nosniff MIME sniffing (a text upload run as script)
X-Frame-Options DENY or SAMEORIGIN Clickjacking in older browsers
Referrer-Policy strict-origin-when-cross-origin Leaking full URLs and tokens to other sites
Permissions-Policy camera=(), microphone=(), geolocation=() Embedded content abusing device APIs
Cross-Origin-Opener-Policy same-origin Cross-window leaks, some side channels

A few notes that the table cannot carry:

  • CSP is the heavyweight. It declares where scripts, styles, images, frames and connections may come from. A policy without 'unsafe-inline' blocks injected <script> tags and inline event handlers, which is what most reflected and stored XSS payloads rely on. The modern pattern is a per-response random nonce on each legitimate script tag, optionally with 'strict-dynamic' so scripts you trust can load their own dependencies.
  • HSTS turns "the first request might be plain HTTP and get stripped" into "the browser refuses HTTP for this host for two years". It only takes effect after the browser has seen it once over HTTPS, which is the gap the preload list closes (more below). The underlying transport is covered in TLS/SSL.
  • frame-ancestors in CSP is the modern replacement for X-Frame-Options. Send both: the CSP directive wins in current browsers, and XFO covers old ones.
  • X-XSS-Protection is obsolete. The browser filter it controlled has been removed from modern browsers and was itself exploitable, so either omit it or send X-XSS-Protection: 0.
  • COOP/COEP/CORP isolate your page from cross-origin windows and resources. Cross-Origin-Embedder-Policy: require-corp plus COOP same-origin makes a page "cross-origin isolated", which is required for features like SharedArrayBuffer. COEP breaks any third-party resource that does not opt in, so only add it when you need it.

A Worked Example: Nonce-Based CSP in Flask#

The hard part of CSP is inline scripts. Instead of allowing all inline code, generate a fresh nonce per response and put it on the scripts you wrote:

import secrets
from flask import Flask, g, render_template_string

app = Flask(__name__)

PAGE = '''<!doctype html>
<h1>Hello</h1>
<script nonce="{{ nonce }}">console.log("trusted inline script");</script>
'''

@app.before_request
def make_nonce():
    # 128 bits of randomness, new for every response
    g.csp_nonce = secrets.token_urlsafe(16)

@app.after_request
def security_headers(resp):
    csp = (
        "default-src 'self'; "
        f"script-src 'nonce-{g.csp_nonce}' 'strict-dynamic'; "
        "object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
    )
    resp.headers["Content-Security-Policy"] = csp
    resp.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains"
    resp.headers["X-Content-Type-Options"] = "nosniff"
    resp.headers["X-Frame-Options"] = "DENY"
    resp.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    resp.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
    return resp

@app.route("/")
def index():
    return render_template_string(PAGE, nonce=g.csp_nonce)

The non-obvious lines:

  • The nonce must be unpredictable and unique per response. A fixed nonce, or one cached by a CDN along with the HTML, is equivalent to 'unsafe-inline' because an attacker can copy it.
  • base-uri 'none' stops an injected <base href> from re-pointing your relative script URLs at another host, a bypass that nonce policies are otherwise vulnerable to.
  • object-src 'none' removes plugin content, an old CSP bypass route.
  • An injected <script> without the nonce is blocked, and the browser console shows a "Refused to execute inline script because it violates the following Content Security Policy directive" message. That console line is the first place to look when CSP breaks something.

If your pages are static and cached, nonces are awkward. Use hashes instead: script-src 'sha256-<base64 of the script body>' allows exactly that inline script and nothing else.

Rolling Out CSP Without Breaking the Site#

CSP is feared because a strict policy on a large legacy site blocks analytics, widgets and old inline handlers the moment you deploy it. Roll it out in stages:

  1. Ship the policy as Content-Security-Policy-Report-Only. The browser evaluates it, blocks nothing, and reports violations.
  2. Collect reports. The older report-uri /csp-report directive makes the browser POST JSON with Content-Type: application/csp-report; the newer mechanism is report-to plus a Reporting-Endpoints header. Many sites send both for coverage.
  3. Fix the real violations (move inline handlers into files, add nonces) and allowlist the legitimate third parties.
  4. Switch to the enforcing header, keep reporting on, and watch for spikes after each deploy.

A minimal report collector for step 2:

from flask import Flask, request
import json, logging

app = Flask(__name__)
logging.basicConfig(level=logging.INFO)

@app.post("/csp-report")
def csp_report():
    # force=True because the content type is application/csp-report, not application/json
    report = request.get_json(force=True, silent=True) or {}
    body = report.get("csp-report", report)
    logging.info("CSP violation: %s blocked %s on %s",
                 body.get("effective-directive") or body.get("violated-directive"),
                 body.get("blocked-uri"), body.get("document-uri"))
    return "", 204

Expect noise: browser extensions inject scripts into pages and generate violations with blocked-uri values like chrome-extension or inline on pages you never changed. Group reports by directive and blocked origin, and fix the groups that come from your own code.

HSTS and the Preload Trap#

HSTS is easy to enable and hard to undo, because browsers cache it for max-age seconds. Sequence it deliberately:

  • Start with a short max-age (for example 300) to prove nothing breaks, then raise it.
  • Add includeSubDomains only after every subdomain serves valid HTTPS. An internal http://legacy.example.com becomes unreachable in browsers that saw the header.
  • preload asks browsers to hard-code your domain into their built-in list. Submission requires a long max-age (at least one year), includeSubDomains, and an HTTP-to-HTTPS redirect. Removal from the list takes months to reach users, so treat preload as a one-way door.

Where to Set Them, and Why They Go Missing#

Headers apply where you set them. Behind a reverse proxy or CDN, the edge is usually the right place, but proxies can also strip or duplicate backend headers. Two nginx traps catch many teams:

server {
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;

    location /api/ {
        # This add_header REPLACES all server-level add_header lines for /api/
        add_header Cache-Control "no-store";
        proxy_pass http://backend;
    }
}
  • Without always, nginx only adds the header on success and redirect statuses, so your 404 and 500 pages go out bare.
  • add_header inside a location block means none of the server-level add_header directives are inherited there. The /api/ responses above have no HSTS at all. Repeat the headers (or use an include snippet) in every block that defines its own.

Other common failures: two different CSP headers (browsers enforce both, so the effective policy is the intersection, which often blocks everything), a CSP delivered via <meta> tag (which cannot use frame-ancestors or reporting), and framework presets that set headers only on HTML routes while file downloads go out without nosniff.

Verify Every Release#

curl -sI https://yoursite.example shows the header set in seconds, but a script can check it on every deploy:

import sys
import requests

EXPECTED = {
    "content-security-policy": None,          # just require presence
    "strict-transport-security": "max-age=",
    "x-content-type-options": "nosniff",
    "referrer-policy": None,
    "x-frame-options": None,
}

def audit(url):
    r = requests.get(url, timeout=10, allow_redirects=True)
    problems = []
    for name, must_contain in EXPECTED.items():
        value = r.headers.get(name)
        if value is None:
            problems.append(f"missing {name}")
        elif must_contain and must_contain not in value.lower():
            problems.append(f"weak {name}: {value}")
    csp = r.headers.get("content-security-policy", "")
    if "'unsafe-inline'" in csp and "nonce-" not in csp:
        problems.append("CSP allows 'unsafe-inline' without nonces")
    return problems

if __name__ == "__main__":
    issues = audit(sys.argv[1])
    print("\n".join(issues) or "all expected headers present")
    sys.exit(1 if issues else 0)

Running it against a site that forgot two headers prints something like:

missing content-security-policy
missing referrer-policy

The non-zero exit code lets you fail a CI job on regressions. Check more than the home page: an error page, an API route and a file download, since those are the responses that usually differ. Browser DevTools (Network tab, response headers) confirms what the browser actually received after every proxy and CDN has touched it.

Practice#

Against a local app, deploy the Flask example, then inject <script>alert(1)</script> through a template variable and confirm in the console that CSP blocked it. Next, remove base-uri 'none', inject <base href="https://attacker.example/"> and observe how relative script URLs are re-pointed. Finally, run the audit script against your 404 page and compare it with the home page. Headers pair naturally with CSRF protections and cookie flags; together they make up most of the browser-side hardening an OWASP Top 10 review looks for.