Skip to content
Back to skills

Set Up Security Headers

ASecurity

Use when hardening a frontend SPA's delivery — one strict Content-Security-Policy and header set (no unsafe-inline scripts), rendered for the project's host (Netlify, Vercel, or self-hosted Caddy/nginx/Traefik), plus Dependabot hygiene and an honest note on the XSS surface.

  • 5 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 10, 2026
developmentrustshellbashreactvuegitapifrontendsecurity

Works with

  • api

Security analysis

A96/100
  • mediumUses curl or wget to download content

Pro scans all 2 files and shows the line behind each finding

Scanned October 10, 2026

npx -y skills add VelimirMueller/lab-claude-skills --skill set-up-security-headers --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Set Up Security Headers?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Set Up Security Headers
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/velimirmueller-set-up-security-headers-lab-claude-skills/badge)](https://www.skillsdirectory.com/skills/velimirmueller-set-up-security-headers-lab-claude-skills)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: set-up-security-headers
description: Use when hardening a frontend SPA's delivery — one strict Content-Security-Policy and header set (no unsafe-inline scripts), rendered for the project's host (Netlify, Vercel, or self-hosted Caddy/nginx/Traefik), plus Dependabot hygiene and an honest note on the XSS surface.
---

# Set Up Security Headers

Security headers are delivered by the host, not the bundle. The policy is the same on every host; only the file that carries it changes. This skill writes the policy once (step 3), renders it for the project's host (step 4), and wires `connect-src` to the origins the app actually calls.

## 1. Audit current state
```bash
cat .claude/stack-profile.md 2>/dev/null || cat ~/.claude/stack-profile.md 2>/dev/null   # hosting, ci
ls netlify.toml public/_headers vercel.json vercel.ts Caddyfile nginx.conf compose*.y*ml .github/dependabot.yml 2>/dev/null
grep -in "content-security-policy\|strict-transport" netlify.toml public/_headers vercel.json vercel.ts Caddyfile nginx.conf 2>/dev/null
grep -n "VITE_API_URL\|VITE_REALTIME_URL" src/libs/env.ts 2>/dev/null   # origins for connect-src
grep -n "<script>" index.html 2>/dev/null                                 # inline scripts break script-src 'self'
```
Pick the host branch from the profile's `hosting` list. If there is no profile, detect it from the files above.

| Profile `hosting` / evidence | Branch |
|---|---|
| `netlify`, or `netlify.toml` | Netlify (4a) |
| `vercel`, or `vercel.json` / `vercel.ts` | Vercel (4b) |
| `hetzner`, `ionos`, or a Caddyfile / nginx conf / Traefik labels | Self-hosted (4c) |
| `supabase`, `none`, or unknown | Ask one question: where is the SPA served from? Do not guess. |

**Prerequisites:** a deployed host (see `configure-ci`). Recommended: `validate-env` (the CSP reads `VITE_API_URL` / `VITE_REALTIME_URL`).

## 2. Decide
- No headers → full setup. Partial → add the missing headers/directives. Present → confirm `connect-src` covers current API + realtime origins.
- An inline `<script>` in `index.html` (theme init, analytics snippet) → move it to a same-origin file in `public/` first. The policy below blocks inline scripts on purpose.

## 3. The policy — one definition, every host
| Header | Value |
|---|---|
| `Content-Security-Policy` | `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.example.com wss://realtime.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'` |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` (add `; preload` only after the HSTS-preload decision in `security-patterns.md`) |
| `X-Content-Type-Options` | `nosniff` |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` |

Replace `https://api.example.com` / `wss://realtime.example.com` with the **origins** of `VITE_API_URL` / `VITE_REALTIME_URL` (scheme + host, no path). Drop the `wss:` origin if realtime isn't set up. Cache rule for every host: `index.html` and the service worker (`sw.js`) must not be cached long-term (`Cache-Control: no-cache`); hashed `/assets/*` can be `immutable`. The SPA fallback (all unknown paths → `/index.html`) is host config too; each block below includes it.

## 4. Render it for the host (a project uses exactly one block)

### 4a. Netlify
`netlify.toml` — the `[[headers]]` block (`configure-ci` owns `[build]`; merge, don't overwrite):
```toml
[[headers]]
  for = "/*"
  [headers.values]
    Content-Security-Policy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.example.com wss://realtime.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
    Strict-Transport-Security = "max-age=31536000; includeSubDomains"
    X-Content-Type-Options = "nosniff"
    Referrer-Policy = "strict-origin-when-cross-origin"
    Permissions-Policy = "camera=(), microphone=(), geolocation=()"

[[headers]]
  for = "/sw.js"
  [headers.values]
    Cache-Control = "no-cache"
```

### 4b. Vercel
`vercel.json` (use `vercel.ts` with `routes.header()` from `@vercel/config/v1` only if the project already has one — a project may hold one config file, not both):
```json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }],
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.example.com wss://realtime.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" },
        { "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" },
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
        { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }
      ]
    },
    { "source": "/sw.js", "headers": [{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }] }
  ]
}
```
Preview deploys and project setup: [`../../infra/deploy-to-vercel/SKILL.md`](../../infra/deploy-to-vercel/SKILL.md). Vercel serves files from the filesystem before it applies the rewrite, so `/assets/*` still resolves.

### 4c. Self-hosted (Hetzner / IONOS)
Caddy serves the built `dist/` and carries the headers (a Caddy container behind or instead of Traefik):
```caddyfile
example.com {
	root * /srv
	encode zstd gzip
	header {
		Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.example.com wss://realtime.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options nosniff
		Referrer-Policy strict-origin-when-cross-origin
		Permissions-Policy "camera=(), microphone=(), geolocation=()"
	}
	@shell path /index.html /sw.js
	header @shell Cache-Control "no-cache"
	try_files {path} /index.html
	file_server
}
```
nginx: the same values as `add_header <name> "<value>" always;` in the `server` block. An `add_header` inside a `location` block **replaces** every inherited one — repeat the full set there or use an `include`. Traefik in front of a plain file server: use the `headers` middleware (`stsSeconds=31536000`, `stsIncludeSubdomains=true`, `contentTypeNosniff=true`, `referrerPolicy`, `contentSecurityPolicy`, `customResponseHeaders.Permissions-Policy`) and attach it to the router. Server, deploy and TLS: [`../../infra/deploy-to-hetzner/SKILL.md`](../../infra/deploy-to-hetzner/SKILL.md).

## 5. CSP notes for a Vite SPA
- Vite emits **external** scripts in prod, so `script-src 'self'` needs no `'unsafe-inline'`. The dev server is looser; the policy applies to the deployed build.
- `style-src 'unsafe-inline'` is a pragmatic default, not a requirement: the Tailwind build is an external CSS file, and React/Vue set `style` through the CSSOM, which CSP allows. Libraries that inject `<style>` tags at runtime need it. Test `style-src 'self'` in Report-Only first (`security-patterns.md`).
- `connect-src` breaks silently: omit your API or `wss://` origin and those requests fail with a console CSP error. That's why it reads from the env seam.
- Trusted Types (`require-trusted-types-for 'script'`) is the next layer. Roll it out in Report-Only (`security-patterns.md`).

## 6. Dependency hygiene
`.github/dependabot.yml`:
```yaml
version: 2
updates:
  - package-ecosystem: npm
    directory: "/"
    schedule:
      interval: weekly
    groups:
      minor-and-patch:
        update-types: [minor, patch]
  - package-ecosystem: github-actions
    directory: "/"
    schedule:
      interval: weekly
```
`configure-ci` runs `pnpm audit`. Automation proposes; a human approves each bump.

## 7. The XSS surface (the honest note)
React and Vue escape interpolated values by default. The real hole is **`dangerouslySetInnerHTML` (React) / `v-html` (Vue)** — they bypass escaping. Avoid them; if unavoidable, sanitize with DOMPurify first. A strict CSP is defense-in-depth, not a substitute. Baseline: [`security-baseline.md`](../../core/_shared/security-baseline.md).

## 8. Verify
```bash
curl -sI https://<deploy-url>/ | grep -i "content-security-policy\|strict-transport\|x-content-type"
curl -sI https://<deploy-url>/sw.js | grep -i cache-control      # if the PWA is on
curl -s -o /dev/null -w "%{http_code}\n" https://<deploy-url>/some/deep/route   # 200 = SPA fallback works
```
Load the app: the console shows **no** CSP violations (every API / realtime / font / image origin is allowed). Grade it at securityheaders.com.

## References
- ./security-patterns.md — edge-vs-meta delivery, the `connect-src`-from-env rule, strict-CSP rollout (Report-Only, Trusted Types), the HSTS-preload decision, supply chain, the XSS surface.
- ../validate-env/SKILL.md — the `env` seam supplying the `connect-src` origins.
- ../configure-ci/SKILL.md — owns the Netlify `[build]` block and the `pnpm audit` step.
- ../../core/_shared/security-baseline.md — the cross-stack header and cookie rules this skill implements.

Files in this skill

  • SKILL.md9.3 KB
  • security-patterns.md6.1 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.