Security Headers skill for Claude Code
Strict CSP shipped without breaking production. Claude ships a nonce-based CSP with strict-dynamic through a Report-Only phase instead of pasting a policy that breaks checkout. It configures HSTS in safe steps, replaces X-Frame-Options with frame-ancestors, fixes CORS that reflects origins with credentials, and knows the nginx and Next.js traps. A bundled checker grades any URL you own and fails CI on regressions.
- name
security-headers - size190 lines · ≈2,150 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Configures and audits HTTP security headers — a strict nonce-based Content-Security-Policy rolled out through Report-Only, HSTS with preload rules, frame-ancestors, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, COOP/COEP/CORP, and correct CORS — in nginx, Express/helmet, Next.js, Django, and CDNs. Use when adding or tightening CSP, fixing clickjacking or CORS findings, failing a header scanner, enabling cross-origin isolation, or when a pentest flags missing headers. Try asking
- Add a strict Content-Security-Policy to our Next.js app without breaking analytics
- Our pentest says CORS is misconfigured and headers are missing — fix it
- Check the security headers on our staging site and tell me what to change
Use it when
- Adding or tightening a Content-Security-Policy
- A scanner or pentest flags missing headers, clickjacking, or CORS issues
- Enabling HSTS or HSTS preload for a domain
- Turning on cross-origin isolation with COOP and COEP
Skip it when
- You need TLS termination, proxying, or caching setup — use nginx-reverse-proxy instead
- The XSS lives in templating or output encoding — use input-validation instead
How to check the result
The header checker exits 0 on an HTML page, an error page and an API route, the browser console shows no CSP violations on core flows, and an untrusted Origin is not reflected.
The common mistake it prevents
Enforcing a new CSP directly instead of running it in Report-Only first, or adding add_header in an nginx location block and silently dropping every inherited header.
Done well means: an enforced CSP that blocks injected scripts (nonces or hashes with
strict-dynamic, no unsafe-inline for scripts), HSTS on every HTTPS response, framing
controlled by frame-ancestors, CORS that never reflects arbitrary origins with
credentials, and a rollout that did not break production because it ran in Report-Only
first.
Baseline set
Send these on every HTML response (most are harmless on API responses too):
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: <see below>
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Cross-Origin-Opener-Policy: same-origin
| Header | Notes |
|---|---|
| HSTS | Start with max-age=300, then 1 week, then 2 years. Add includeSubDomains only when every subdomain serves HTTPS. preload requires ≥ 1 year + includeSubDomains; removal from browser preload lists takes months |
| X-Frame-Options | Superseded by CSP frame-ancestors. Send DENY too only for legacy browsers |
| X-XSS-Protection | Obsolete; the filter itself caused vulnerabilities. Send 0 or omit |
COOP same-origin | Isolates your window from cross-origin popups. Use same-origin-allow-popups if you rely on OAuth/payment popups that need window.opener |
| COEP | Only for cross-origin isolation (SharedArrayBuffer, high-res timers). require-corp or credentialless. Breaks third-party embeds lacking CORP/CORS |
| CORP | Cross-Origin-Resource-Policy: same-site on private resources (user images, API JSON) to block cross-site loads |
Content-Security-Policy
Recommended strict policy (nonce-based):
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
frame-ancestors 'self';
form-action 'self';
report-to csp-endpoint
Why it looks odd: browsers that support strict-dynamic ignore https: and
'unsafe-inline' when a nonce is present; those two only serve as fallbacks for very old
browsers. Scripts loaded by a nonced script are trusted transitively, which makes tag
managers and bundler chunks work without allowlisting hosts.
Rules:
- Generate a fresh nonce per response (≥ 128 bits, base64) and put it on every
<script nonce="...">. Never cache HTML with a fixed nonce at the CDN. - Static sites that cannot vary per response: use hashes (
'sha256-...') of inline scripts instead of nonces. - Host allowlists (
script-src https://cdn.example.com) are weak: any JSONP endpoint or old library on an allowed host bypasses them. Prefer nonces. - Inline event handlers (
onclick=) andjavascript:URLs are blocked by a strict policy. Refactor them toaddEventListener. style-src 'unsafe-inline'is a much smaller risk than inscript-src; accept it initially if CSS-in-JS needs it.- Add
upgrade-insecure-requestswhile migrating mixed content.
Rollout through Report-Only
- [ ] 1. Inventory scripts: inline blocks, event handlers, third-party tags, eval use
- [ ] 2. Deploy Content-Security-Policy-Report-Only with the target policy + reporting
- [ ] 3. Collect reports for 1–2 weeks across real traffic; fix code, not the policy
- [ ] 4. Enforce: rename header to Content-Security-Policy; keep reporting
- [ ] 5. Optionally keep a stricter Report-Only policy alongside to test the next step
Reporting setup:
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: ...; report-to csp-endpoint; report-uri https://example.com/csp-reports
report-to is the current mechanism; keep report-uri too because Firefox support for
report-to has lagged. Reports arrive as application/reports+json (report-to) or
application/csp-report (report-uri). Expect noise from browser extensions
(chrome-extension://, moz-extension:// in source-file); filter it out.
Framework configuration
nginx (note always so headers appear on error responses too):
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "object-src 'none'; base-uri 'none'; frame-ancestors 'self'" always;
Gotcha: an add_header inside a location block discards all add_header directives
inherited from server. Repeat the full set or use an include snippet.
Express with helmet:
import crypto from "node:crypto";
import helmet from "helmet";
app.use((req, res, next) => { res.locals.cspNonce = crypto.randomBytes(16).toString("base64"); next(); });
app.use(helmet({
contentSecurityPolicy: {
directives: {
scriptSrc: [(req, res) => `'nonce-${res.locals.cspNonce}'`, "'strict-dynamic'"],
objectSrc: ["'none'"],
baseUri: ["'none'"],
frameAncestors: ["'self'"],
},
},
strictTransportSecurity: { maxAge: 63072000, includeSubDomains: true, preload: true },
}));
Next.js (App Router): generate the nonce in middleware.ts, set the CSP on the response
and an x-nonce request header; read it with headers() in the root layout and pass to
<Script nonce>. Nonces force dynamic rendering of those pages. Static headers
(HSTS, nosniff) go in next.config.js headers().
Django: SecurityMiddleware with SECURE_HSTS_SECONDS, SECURE_HSTS_INCLUDE_SUBDOMAINS,
SECURE_HSTS_PRELOAD, SECURE_CONTENT_TYPE_NOSNIFF, SECURE_REFERRER_POLICY,
SECURE_CROSS_ORIGIN_OPENER_POLICY. Django 6.0 adds built-in CSP settings and nonce
support; on older versions use the django-csp package.
CDNs (Cloudflare, CloudFront, Vercel, Netlify): set static headers at the edge
(Transform Rules, Response Headers Policies, vercel.json, _headers). Per-request
nonces need edge or origin code.
CORS
CORS relaxes the same-origin policy; it is not a protection layer.
- Never reflect the request
OriginwithAccess-Control-Allow-Credentials: true. Compare against an exact allowlist (scheme + host + port). Access-Control-Allow-Origin: *is fine for truly public, unauthenticated resources; browsers refuse*with credentials.- Do not allow
nullorigin. - Add
Vary: Originwhen the response depends on the origin (prevents cache poisoning). - Regex allowlists must be anchored and escape dots:
^https://([a-z0-9-]+\.)?example\.com$. An unanchoredexample.commatchesexample.com.attacker.net. - Cache preflights with
Access-Control-Max-Age(browsers cap it; Chromium at 2 hours).
Audit
Check a live URL you own (standard library, no installs):
python3 scripts/check_headers.py https://example.com
python3 scripts/check_headers.py https://api.example.com/v1/me --cors-probe
python3 scripts/check_headers.py https://example.com --json > headers.json
It reports PASS/WARN/FAIL/INFO for HSTS, CSP quality (unsafe-inline without nonce,
wildcards, missing object-src/base-uri/frame-ancestors), framing, nosniff,
Referrer-Policy, COOP, legacy headers, version disclosure, and (with --cors-probe)
whether an untrusted Origin is reflected with credentials. Exit code 1 on any FAIL, so it
works as a CI gate. On macOS python.org builds, if you see CERTIFICATE_VERIFY_FAILED,
run with SSL_CERT_FILE=/etc/ssl/cert.pem.
Quick manual check: curl -sI https://example.com | grep -iE "strict-transport|content-security|x-content-type|referrer|permissions|cross-origin".
Check several paths: the homepage, a 404, an API route, a static asset, and a redirect.
Gotchas
- A
<meta http-equiv="Content-Security-Policy">cannot setframe-ancestors,report-to, orsandbox; use the header. - Two CSP headers are both enforced (intersection), which surprises teams when a CDN and the app each add one.
- HSTS on HTTP responses is ignored; HSTS on a staging subdomain with
includeSubDomainsset on the apex can break internal HTTP tools. - Third-party scripts (analytics, chat widgets) are the usual CSP blockers; with
strict-dynamiconly the loader tag needs a nonce. - Report endpoints receive attacker-controlled JSON; size-limit and rate-limit them.
Verification
check_headers.pyexits 0 on the main HTML route, an error page, and an API route.- Browser devtools console shows no CSP violations on core flows after enforcement.
- An injected
<script>alert(1)</script>in a test page (local environment) is blocked. - CORS probe from an untrusted origin is not reflected; allowed origins still work.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/check_headers.py
#!/usr/bin/env python3
"""Check HTTP security headers (and optionally CORS reflection) for a URL you own.
Usage:
python3 check_headers.py https://example.com
python3 check_headers.py https://app.example.com/api/me --cors-probe
python3 check_headers.py https://example.com --json
Only run against sites you own or are authorized to test.
Exit codes: 0 = no FAIL results, 1 = at least one FAIL, 2 = request error.
Standard library only (Python 3.10+).
"""
import argparse
import json
import re
import sys
import urllib.error
import urllib.request
UA = "security-headers-check/1.0"
def fetch(url, extra=None, method="GET"):
req = urllib.request.Request(url, method=method, headers={"User-Agent": UA, **(extra or {})})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.geturl(), resp.status, resp.headers
except urllib.error.HTTPError as err: # still has headers
return err.geturl(), err.code, err.headers
def parse_csp(value):
directives = {}
for part in value.split(";"):
tokens = part.strip().split()
if tokens:
directives[tokens[0].lower()] = [t for t in tokens[1:]]
return directives
def check(headers, final_url):
results = [] # (level, header, message)
add = lambda lvl, h, m: results.append((lvl, h, m))
get = lambda name: headers.get(name)
# HSTS
hsts = get("Strict-Transport-Security")
if not final_url.startswith("https://"):
add("FAIL", "Strict-Transport-Security", "final URL is not HTTPS")
elif not hsts:
add("FAIL", "Strict-Transport-Security", "missing")
else:
m = re.search(r"max-age=(\d+)", hsts)
age = int(m.group(1)) if m else 0
if age < 15552000:
add("WARN", "Strict-Transport-Security", f"max-age={age} is below 180 days")
else:
add("PASS", "Strict-Transport-Security", hsts)
if "preload" in hsts and (age < 31536000 or "includesubdomains" not in hsts.lower()):
add("WARN", "Strict-Transport-Security", "preload requires max-age>=31536000 and includeSubDomains")
# CSP
csp = get("Content-Security-Policy")
csp_ro = get("Content-Security-Policy-Report-Only")
if not csp:
add("FAIL" if not csp_ro else "WARN", "Content-Security-Policy",
"missing" + (" (Report-Only present: not enforced yet)" if csp_ro else ""))
else:
d = parse_csp(csp)
script = d.get("script-src", d.get("default-src", []))
has_nonce_or_hash = any(t.startswith(("'nonce-", "'sha256-", "'sha384-", "'sha512-")) for t in script)
if "'unsafe-inline'" in script and not has_nonce_or_hash:
add("FAIL", "Content-Security-Policy", "script-src allows 'unsafe-inline' without nonce/hash")
if "'unsafe-eval'" in script:
add("WARN", "Content-Security-Policy", "script-src allows 'unsafe-eval'")
if any(t in ("*", "https:", "http:", "data:") for t in script):
add("FAIL", "Content-Security-Policy", "script-src allows a scheme or wildcard source")
if not script:
add("FAIL", "Content-Security-Policy", "no script-src or default-src")
for needed, why in (("object-src", "set object-src 'none'"),
("base-uri", "set base-uri 'none' or 'self'"),
("frame-ancestors", "set frame-ancestors to prevent clickjacking")):
if needed == "object-src" and d.get("default-src") == ["'none'"]:
continue # object-src falls back to default-src; base-uri/frame-ancestors do not
if needed not in d:
add("WARN", "Content-Security-Policy", f"no {needed}: {why}")
if not any(r.startswith("FAIL") for r, h, _ in results if h == "Content-Security-Policy"):
add("PASS", "Content-Security-Policy", csp[:160] + ("..." if len(csp) > 160 else ""))
# Clickjacking
if not (csp and "frame-ancestors" in parse_csp(csp)) and not get("X-Frame-Options"):
add("FAIL", "frame-ancestors / X-Frame-Options", "page can be framed by any origin")
# Simple headers
xcto = get("X-Content-Type-Options")
add("PASS" if xcto and xcto.lower() == "nosniff" else "FAIL", "X-Content-Type-Options", xcto or "missing")
rp = get("Referrer-Policy")
weak = {"unsafe-url", "no-referrer-when-downgrade"}
if not rp:
add("WARN", "Referrer-Policy", "missing (browser default is strict-origin-when-cross-origin)")
elif rp.lower() in weak:
add("WARN", "Referrer-Policy", f"{rp} leaks full URLs cross-origin")
else:
add("PASS", "Referrer-Policy", rp)
add("PASS" if get("Permissions-Policy") else "INFO", "Permissions-Policy", get("Permissions-Policy") or "missing")
add("PASS" if get("Cross-Origin-Opener-Policy") else "INFO", "Cross-Origin-Opener-Policy",
get("Cross-Origin-Opener-Policy") or "missing (consider same-origin)")
xxss = get("X-XSS-Protection")
if xxss and xxss.strip() not in ("0",):
add("WARN", "X-XSS-Protection", f"{xxss}: legacy filter; set to 0 or remove")
for leak in ("Server", "X-Powered-By", "X-AspNet-Version"):
v = get(leak)
if v and re.search(r"\d", v):
add("INFO", leak, f"discloses version: {v}")
return results
def cors_probe(url):
evil = "https://cors-probe.invalid"
_, _, h = fetch(url, {"Origin": evil})
acao, acac = h.get("Access-Control-Allow-Origin"), h.get("Access-Control-Allow-Credentials")
if acao == evil and (acac or "").lower() == "true":
return [("FAIL", "CORS", "reflects arbitrary Origin WITH credentials: cross-site data theft")]
if acao == evil:
return [("WARN", "CORS", "reflects arbitrary Origin (no credentials)")]
if acao == "null":
return [("WARN", "CORS", "allows Origin: null (sandboxed iframes can use it)")]
return [("PASS", "CORS", f"Access-Control-Allow-Origin={acao or 'absent'} for untrusted origin")]
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("url")
ap.add_argument("--cors-probe", action="store_true", help="send a request with an untrusted Origin")
ap.add_argument("--json", action="store_true", help="machine-readable output")
args = ap.parse_args()
try:
final, status, headers = fetch(args.url)
except Exception as exc: # noqa: BLE001
print(f"error: {exc}", file=sys.stderr)
return 2
results = check(headers, final)
if args.cors_probe:
results += cors_probe(final)
if args.json:
print(json.dumps({"url": final, "status": status,
"results": [dict(level=l, header=h, detail=m) for l, h, m in results]}, indent=2))
else:
print(f"{final} (HTTP {status})")
order = {"FAIL": 0, "WARN": 1, "INFO": 2, "PASS": 3}
for lvl, h, m in sorted(results, key=lambda r: order[r[0]]):
print(f" [{lvl}] {h}: {m}")
return 1 if any(r[0] == "FAIL" for r in results) else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.