Security Headers skill for Codex

Every response, every header, one layer. Codex audits real responses, including error pages and redirects, with a bundled standard-library script that grades each security header and flags duplicates and version leaks. It then sets headers in a single layer of your stack, stages HSTS from minutes to two years before any preload decision, and checks that COOP and COEP changes do not break OAuth popups or embeds.

  • Security
  • $security-headers
  • 170 lines
  • 3 files
  • ~1,950 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- security-headers

Installs to ~/.agents/skills/security-headers/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $security-headers, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Audits and sets HTTP security response headers - Strict-Transport-Security and HSTS preload, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, Cross-Origin-Resource-Policy, frame protection and cache headers - in nginx, Caddy, Express, Django, Rails, Spring, Next.js or a CDN. Includes a standard-library header audit script. Use when hardening a site's headers, fixing a scanner finding, enabling cross-origin isolation for SharedArrayBuffer, or reviewing HSTS before preload.

Use it when

  • A scanner or pentest flagged missing or weak headers
  • Rolling out HSTS or considering preload
  • Enabling cross-origin isolation (COOP/COEP)
  • Headers differ between app, proxy and CDN layers

Use something else when

How to check the result

audit_headers.py passes for HTML, API, static, error and redirect URLs, no header is sent twice, and OAuth popups and embeds still work in a browser.

The mistake it prevents

Adding includeSubDomains or preload before every subdomain serves HTTPS, locking users out of forgotten hosts for the full max-age.

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

--- name: security-headers description: Audits and sets HTTP security response headers - Strict-Transport-Security and HSTS preload, X-Content-Type-Options, Referrer-Policy,… license: MIT ---

Done well means: every HTML and API response carries the right headers from one place in the stack, HSTS is rolled out in stages so it cannot lock users out, and the result is checked with a real request against every response type (HTML, API, static file, error page, redirect).

Inspect first

  1. Find where headers are set today; often several layers fight each other:
rg -n -i "strict-transport|x-frame-options|x-content-type|referrer-policy|permissions-policy|cross-origin-(opener|embedder|resource)|add_header|helmet|SECURE_HSTS|headers\(\)" -g '!node_modules'
  1. Check the deployed or local response, including redirects and errors:
python3 scripts/audit_headers.py https://localhost:8443/ https://localhost:8443/api/health https://localhost:8443/does-not-exist

The script does not follow redirects (a 3xx is checked for HSTS and disclosure, and its target is printed), prints every security header with a verdict (ok, weak, missing, remove, duplicate), and exits 1 if anything needs fixing. It needs network access to the target; against a local dev server that is usually available even in a sandbox. Use --insecure for self-signed local certificates.

Target values

HeaderRecommended valueNotes
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadHTTPS responses only. Stage it (below)
Content-Security-PolicyStrict nonce/hash policySeparate workflow: $content-security-policy
X-Content-Type-OptionsnosniffAll responses
Referrer-Policystrict-origin-when-cross-origin (or no-referrer on pages with tokens in URLs)
Permissions-Policycamera=(), microphone=(), geolocation=(), payment=(), usb=()Allow only features the app uses, e.g. camera=(self)
Cross-Origin-Opener-Policysame-originsame-origin-allow-popups if OAuth or payment popups need window.opener
Cross-Origin-Resource-Policysame-origin for private assets/APIs, cross-origin for public CDN assets
Cross-Origin-Embedder-Policyrequire-corp or credentiallessOnly when you need cross-origin isolation
Frame protectionCSP frame-ancestors 'self' plus X-Frame-Options: DENY or SAMEORIGIN for old browsers
Cache-Controlno-store on authenticated pages and API responses with personal data
Clear-Site-Data"cache", "cookies", "storage" on the logout response

Remove or neutralise:

HeaderAction
X-Powered-By, detailed Server, X-AspNet-VersionRemove (version disclosure)
X-XSS-ProtectionRemove or set 0; the old filter introduced vulnerabilities
Expect-CT, Public-Key-PinsRemove; obsolete
Access-Control-Allow-Origin: * on authenticated APIsFix with $cors-configuration

HSTS rollout

HSTS is sticky in browsers for max-age seconds, and the preload list is compiled into browsers; removal takes months. Stage it:

- [ ] Every subdomain serves valid HTTPS (inventory DNS first: internal tools, mail, legacy hosts)
- [ ] max-age=300 for a day; watch for broken subdomains
- [ ] max-age=86400 with includeSubDomains for a week
- [ ] max-age=31536000 (or 63072000) with includeSubDomains
- [ ] Only then add preload and submit at hstspreload.org (requires max-age >= 31536000,
      includeSubDomains, preload, and an HTTP->HTTPS redirect on the apex)

Preload submission affects every subdomain permanently for practical purposes: confirm with the user before adding preload or submitting.

Cross-origin isolation

Needed for SharedArrayBuffer, high-resolution timers and some WebAssembly threading:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Every cross-origin subresource must then send Cross-Origin-Resource-Policy: cross-origin or be loaded with CORS (crossorigin attribute). If third-party images or iframes cannot comply, use COEP: credentialless. Check self.crossOriginIsolated === true in the browser console to confirm.

Where to set them

Set headers in one layer, preferably the edge or reverse proxy for static values and the app for per-response values (CSP nonces, Cache-Control).

nginx (always adds the header on 4xx/5xx too):

server {
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header X-Frame-Options "DENY" always;
    server_tokens off;
    proxy_hide_header X-Powered-By;
}

nginx trap: any add_header inside a location block discards all inherited add_header lines from the server block. Put the set in an include file and include it in each location that adds its own headers.

Caddy: a header directive block in the site; -Server removes the Server header.

Express: helmet() sets sane defaults (including a basic CSP you will want to replace); configure hsts, referrerPolicy, and crossOriginEmbedderPolicy explicitly.

Django: SECURE_HSTS_SECONDS, SECURE_HSTS_INCLUDE_SUBDOMAINS, SECURE_HSTS_PRELOAD, SECURE_CONTENT_TYPE_NOSNIFF, SECURE_REFERRER_POLICY, SECURE_CROSS_ORIGIN_OPENER_POLICY, X_FRAME_OPTIONS = "DENY", via SecurityMiddleware. Behind a TLS-terminating proxy also set SECURE_PROXY_SSL_HEADER, or HSTS is never sent.

Rails: config.force_ssl = true (sets HSTS), config.ssl_options = { hsts: { subdomains: true, preload: false, expires: 2.years } }, config.action_dispatch.default_headers for the rest.

Next.js: headers() in next.config.js for static values; middleware for per-request.

Spring Security: http.headers(h -> h.httpStrictTransportSecurity(...).referrerPolicy(...).permissionsPolicyHeader(...)).

Workflow

- [ ] 1. Run the audit script against HTML, API, static, error and redirect URLs
- [ ] 2. Choose one layer to own each header; remove duplicates elsewhere
- [ ] 3. Add missing headers; start HSTS at a short max-age
- [ ] 4. Remove disclosure and obsolete headers
- [ ] 5. Re-run the audit; check error pages and static assets specifically
- [ ] 6. Test the app in a browser: logins, OAuth popups, embeds, file downloads

Verification

  • scripts/audit_headers.py exits 0 for every representative URL.
  • No duplicates: the script flags any security header sent twice (two CSP or HSTS headers from app and proxy are a bug; browsers enforce both CSPs).
  • Browser DevTools > Network > a response > Headers shows the same values the script saw (a CDN may add or strip headers).
  • Functional smoke test: OAuth popups still close and return (COOP), third-party embeds still load (CORP/COEP), downloads still open.

Output template

## Security headers: <host>
| Header | Before | After | Set in |
|---|---|---|---|
| Strict-Transport-Security | missing | max-age=86400; includeSubDomains (stage 2 of 4) | nginx |
URLs checked: <list>   Audit script: pass/fail
Pending: HSTS increase on <date>, preload decision (needs user confirmation)

Gotchas

  • HSTS sent over plain HTTP is ignored; it must be on the HTTPS response.
  • includeSubDomains on the apex breaks any HTTP-only subdomain, including ones you forgot (printers, legacy admin tools).
  • Headers set only on 200 responses leave error pages frameable and sniffable.
  • X-Frame-Options: ALLOW-FROM is unsupported in modern browsers; use frame-ancestors.
  • COOP same-origin breaks OAuth and payment flows that rely on window.opener.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Security Headers"
  short_description: "Every response, every header, one layer"
  default_prompt: "$security-headers Our scanner says we're missing security headers — fix them in nginx"
scripts/audit_headers.py164 lines, 6.6 KB
#!/usr/bin/env python3
"""Audit HTTP security response headers for one or more URLs.

Usage: python3 audit_headers.py [--insecure] URL [URL ...]

Sends a GET per URL without following redirects; a 3xx is checked only for
HSTS and disclosure headers, and its Location is printed so you can audit it. Prints a verdict per header: ok, weak, missing,
remove, duplicate. Exit 0 when every URL passes, 1 when any header is missing,
weak, duplicated or should be removed, 2 on usage or connection errors.
Standard library only.
"""
import re
import ssl
import sys
import urllib.error
import urllib.request


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):  # report the 3xx itself
        return None


def check_hsts(v, https):
    if not https:
        return "info", "not expected on plain HTTP"
    if v is None:
        return "missing", "add max-age=63072000; includeSubDomains"
    m = re.search(r"max-age\s*=\s*\"?(\d+)", v, re.I)
    age = int(m.group(1)) if m else 0
    if age < 15552000:
        return "weak", f"max-age={age} (< 180 days; fine only during staged rollout)"
    note = "includeSubDomains" if "includesubdomains" in v.lower() else "no includeSubDomains"
    return "ok", f"max-age={age}, {note}"


def check_equals(expected):
    def f(v, _https):
        if v is None:
            return "missing", f"set {expected}"
        return ("ok", v) if v.strip().lower() == expected.lower() else ("weak", f"{v!r}, expected {expected}")
    return f


def check_referrer(v, _https):
    good = {"no-referrer", "strict-origin-when-cross-origin", "same-origin", "strict-origin", "no-referrer-when-downgrade"}
    if v is None:
        return "missing", "set strict-origin-when-cross-origin (browser default, but be explicit)"
    last = v.split(",")[-1].strip().lower()
    if last in ("unsafe-url", "origin-when-cross-origin", "origin"):
        return "weak", f"{v} leaks full URLs or origins cross-site"
    return ("ok", v) if last in good else ("weak", v)


def check_frames(headers, _https):
    csp = headers.get("content-security-policy") or ""
    xfo = headers.get("x-frame-options")
    if "frame-ancestors" in csp.lower():
        return "ok", "CSP frame-ancestors" + (f" + XFO {xfo}" if xfo else "")
    if xfo and xfo.strip().upper() in ("DENY", "SAMEORIGIN"):
        return "ok", f"X-Frame-Options {xfo}"
    return "missing", "add CSP frame-ancestors 'self' (and X-Frame-Options DENY)"


def check_csp(v, _https):
    if v is None:
        return "missing", "see content-security-policy skill"
    low = v.lower()
    issues = []
    if "script-src" not in low and "default-src" not in low:
        issues.append("no script-src")
    if "'unsafe-inline'" in low and "nonce-" not in low and "sha256-" not in low:
        issues.append("unsafe-inline without nonce/hash")
    if "object-src" not in low and "default-src 'none'" not in low:
        issues.append("no object-src")
    return ("weak", "; ".join(issues)) if issues else ("ok", "present")


SIMPLE = {
    "x-content-type-options": check_equals("nosniff"),
    "referrer-policy": check_referrer,
    "content-security-policy": check_csp,
}
OPTIONAL = ["permissions-policy", "cross-origin-opener-policy", "cross-origin-resource-policy", "cross-origin-embedder-policy"]
REMOVE = {"x-powered-by": "version disclosure", "x-aspnet-version": "version disclosure",
          "x-aspnetmvc-version": "version disclosure", "public-key-pins": "obsolete", "expect-ct": "obsolete"}
TRACKED = ["strict-transport-security", "content-security-policy", "x-frame-options", "x-content-type-options",
           "referrer-policy", "permissions-policy", "cross-origin-opener-policy"]


def audit(url, ctx):
    opener = urllib.request.build_opener(NoRedirect, urllib.request.HTTPSHandler(context=ctx))
    req = urllib.request.Request(url, headers={"User-Agent": "audit-headers/1.0"})
    try:
        resp = opener.open(req, timeout=15)
    except urllib.error.HTTPError as e:  # 3xx/4xx/5xx still carry headers
        resp = e
    status = resp.status if hasattr(resp, "status") else resp.code
    raw = resp.headers
    headers = {k.lower(): v for k, v in raw.items()}
    https = url.lower().startswith("https://")
    rows = []
    v, n = check_hsts(headers.get("strict-transport-security"), https)
    rows.append(("strict-transport-security", v, n))
    if 300 <= status < 400:  # redirects need HSTS and no disclosure; page headers matter on the target
        rows.append(("(redirect)", "info", f"-> {headers.get('location', '?')}; audit the target URL too"))
        return status, rows + disclosure_rows(raw, headers)
    for name, fn in SIMPLE.items():
        verdict, note = fn(headers.get(name), https)
        rows.append((name, verdict, note))
    verdict, note = check_frames(headers, https)
    rows.append(("frame protection", verdict, note))
    for name in OPTIONAL:
        val = headers.get(name)
        rows.append((name, "ok" if val else "info", val or "not set (recommended unless it breaks a flow)"))
    return status, rows + disclosure_rows(raw, headers)


def disclosure_rows(raw, headers):
    rows = []
    xxss = headers.get("x-xss-protection")
    if xxss is not None and xxss.strip() != "0":
        rows.append(("x-xss-protection", "remove", f"{xxss!r}; set 0 or remove"))
    for name, why in REMOVE.items():
        if name in headers:
            rows.append((name, "remove", f"{headers[name]!r}: {why}"))
    server = headers.get("server")
    if server and re.search(r"\d", server):
        rows.append(("server", "remove", f"{server!r} discloses a version"))
    for name in TRACKED:
        count = len(raw.get_all(name) or [])
        if count > 1:
            rows.append((name, "duplicate", f"sent {count} times; keep one layer"))
    return rows


def main(argv):
    insecure = "--insecure" in argv
    urls = [a for a in argv if a != "--insecure"]
    if not urls:
        print(__doc__.strip(), file=sys.stderr)
        return 2
    ctx = ssl.create_default_context()
    if insecure:
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
    failed = False
    for url in urls:
        try:
            status, rows = audit(url, ctx)
        except (urllib.error.URLError, OSError, ValueError) as e:
            print(f"\n{url}\n  ERROR: {e}")
            return 2
        print(f"\n{url}  [HTTP {status}]")
        for name, verdict, note in rows:
            print(f"  {verdict:<9} {name:<30} {note}")
            if verdict in ("missing", "weak", "remove", "duplicate"):
                failed = True
    print("\nRESULT:", "FAIL" if failed else "PASS")
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc