Security Headers: CSP, HSTS, and Friends
On this page
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-ancestorsin CSP is the modern replacement forX-Frame-Options. Send both: the CSP directive wins in current browsers, and XFO covers old ones.X-XSS-Protectionis obsolete. The browser filter it controlled has been removed from modern browsers and was itself exploitable, so either omit it or sendX-XSS-Protection: 0.- COOP/COEP/CORP isolate your page from cross-origin windows and resources.
Cross-Origin-Embedder-Policy: require-corpplus COOPsame-originmakes a page "cross-origin isolated", which is required for features likeSharedArrayBuffer. 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:
- Ship the policy as
Content-Security-Policy-Report-Only. The browser evaluates it, blocks nothing, and reports violations. - Collect reports. The older
report-uri /csp-reportdirective makes the browser POST JSON withContent-Type: application/csp-report; the newer mechanism isreport-toplus aReporting-Endpointsheader. Many sites send both for coverage. - Fix the real violations (move inline handlers into files, add nonces) and allowlist the legitimate third parties.
- 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
includeSubDomainsonly after every subdomain serves valid HTTPS. An internalhttp://legacy.example.combecomes unreachable in browsers that saw the header. preloadasks 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_headerinside alocationblock means none of the server-leveladd_headerdirectives are inherited there. The/api/responses above have no HSTS at all. Repeat the headers (or use anincludesnippet) 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.
Check your understanding
4 quick questions on this lesson. Get them all right and the lesson is marked complete.