Security No. 286

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.

  • namesecurity-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

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.

~/.claude/skills/security-headers/SKILL.md

--- name: security-headers description: Configures and audits HTTP security headers — a strict nonce-based Content-Security-Policy rolled out through Report-Only, HSTS with… license: MIT ---

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
HeaderNotes
HSTSStart 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-OptionsSuperseded by CSP frame-ancestors. Send DENY too only for legacy browsers
X-XSS-ProtectionObsolete; the filter itself caused vulnerabilities. Send 0 or omit
COOP same-originIsolates your window from cross-origin popups. Use same-origin-allow-popups if you rely on OAuth/payment popups that need window.opener
COEPOnly for cross-origin isolation (SharedArrayBuffer, high-res timers). require-corp or credentialless. Breaks third-party embeds lacking CORP/CORS
CORPCross-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=) and javascript: URLs are blocked by a strict policy. Refactor them to addEventListener.
  • style-src 'unsafe-inline' is a much smaller risk than in script-src; accept it initially if CSS-in-JS needs it.
  • Add upgrade-insecure-requests while 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 Origin with Access-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 null origin.
  • Add Vary: Origin when the response depends on the origin (prevents cache poisoning).
  • Regex allowlists must be anchored and escape dots: ^https://([a-z0-9-]+\.)?example\.com$. An unanchored example.com matches example.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 set frame-ancestors, report-to, or sandbox; 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 includeSubDomains set on the apex can break internal HTTP tools.
  • Third-party scripts (analytics, chat widgets) are the usual CSP blockers; with strict-dynamic only the loader tag needs a nonce.
  • Report endpoints receive attacker-controlled JSON; size-limit and rate-limit them.

Verification

  • check_headers.py exits 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 161 lines · 7.0 KB
#!/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.

esc