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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- security-headers Installs to ~/.agents/skills/security-headers/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install security-headers The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project security-headers Installs to .agents/skills/security-headers/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/security-headers.zipunzip -o security-headers.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) security-headers PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- You need a strict CSP designed and rolled out: use content-security-policy
- The problem is cross-origin API access: use cors-configuration
- You are tuning TLS versions and ciphers: use tls-hardening
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.
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
- 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'
- 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
| Header | Recommended value | Notes |
|---|---|---|
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | HTTPS responses only. Stage it (below) |
Content-Security-Policy | Strict nonce/hash policy | Separate workflow: $content-security-policy |
X-Content-Type-Options | nosniff | All responses |
Referrer-Policy | strict-origin-when-cross-origin (or no-referrer on pages with tokens in URLs) | |
Permissions-Policy | camera=(), microphone=(), geolocation=(), payment=(), usb=() | Allow only features the app uses, e.g. camera=(self) |
Cross-Origin-Opener-Policy | same-origin | same-origin-allow-popups if OAuth or payment popups need window.opener |
Cross-Origin-Resource-Policy | same-origin for private assets/APIs, cross-origin for public CDN assets | |
Cross-Origin-Embedder-Policy | require-corp or credentialless | Only when you need cross-origin isolation |
| Frame protection | CSP frame-ancestors 'self' plus X-Frame-Options: DENY or SAMEORIGIN for old browsers | |
Cache-Control | no-store on authenticated pages and API responses with personal data | |
Clear-Site-Data | "cache", "cookies", "storage" on the logout response |
Remove or neutralise:
| Header | Action |
|---|---|
X-Powered-By, detailed Server, X-AspNet-Version | Remove (version disclosure) |
X-XSS-Protection | Remove or set 0; the old filter introduced vulnerabilities |
Expect-CT, Public-Key-Pins | Remove; obsolete |
Access-Control-Allow-Origin: * on authenticated APIs | Fix 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.pyexits 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.
includeSubDomainson the apex breaks any HTTP-only subdomain, including ones you forgot (printers, legacy admin tools).- Headers set only on
200responses leave error pages frameable and sniffable. X-Frame-Options: ALLOW-FROMis unsupported in modern browsers; useframe-ancestors.- COOP
same-originbreaks OAuth and payment flows that rely onwindow.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.yaml
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.py
#!/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.