diff --git a/docs/09-security-privacy.md b/docs/09-security-privacy.md
index 8aeffc8..3fea340 100644
--- a/docs/09-security-privacy.md
+++ b/docs/09-security-privacy.md
@@ -34,7 +34,7 @@
| Risk | Countermeasure |
|---|---|
-| XSS through Markdown from arbitrary npubs | `rehype-sanitize` with a strict allowlist, no `dangerouslySetInnerHTML`, no raw HTML, no `javascript:` links |
+| XSS through Markdown from arbitrary npubs | `rehype-sanitize` with a strict allowlist, no `dangerouslySetInnerHTML`, no raw HTML, no `javascript:` links — and a Content-Security-Policy behind it as a second layer, see below |
| Images/iframes used as trackers | **Accepted trade:** every image is loaded directly, whatever host it points at — so a host learns the reader's IP, which page is being read and when, and can count reads. No iframes. See "Images are loaded directly" below |
| Forged `h` tags (an event from another group smuggled in) | Checked after loading: `h` must match the open space, otherwise the event is discarded |
| Forgetting to verify signatures | Verification is enforced in the data layer, not optional per call |
@@ -42,6 +42,200 @@
| Spam in open spaces | Relay rate limits + moderated deletion (`9005`) + a "members only" UI filter |
| Key theft through the app | No handling of nsec at all. NIP-07/NIP-46 only |
+## Content-Security-Policy
+
+The sanitising pipeline above is the first layer, and as of this writing it is
+correct — the CSP closes no hole that is open today. It is the **second** layer,
+and it exists for the day a regression in that pipeline, or a compromised npm
+dependency somewhere in the tree, puts a script into the page anyway. The app
+renders Markdown written by arbitrary public keys, so that day is worth
+planning for.
+
+**This section describes a policy the app is *meant* to be served with. Today
+nothing in production sends it — see "The risk" at the end before reading any
+further.**
+
+The policy is defined once, in `src/csp.ts`, which `vite.config.ts` imports:
+
+```
+default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
+img-src * data:; connect-src 'self' https: wss:; font-src 'self';
+object-src 'none'; frame-src 'none'; worker-src 'none'; base-uri 'none';
+form-action 'none'; frame-ancestors 'none'
+```
+
+### A header, not a meta tag
+
+`` silently ignores
+`frame-ancestors`, `report-uri`/`report-to` and `sandbox`; they are only
+honoured on an HTTP response header. `frame-ancestors 'none'` is part of this
+policy, so a meta tag could not deliver it. A meta tag would also break
+`npm run dev` (see below) and leave two copies of the policy to keep in sync.
+
+`vite.config.ts` puts the header on `preview.headers`, so
+`npm run build && npm run preview` serves the **production build** under the
+policy. That makes it checkable on the artefact that actually ships, without
+inventing a deployment — with one deliberate difference in `connect-src`, see
+"Preview relaxes `connect-src`, and only that" below.
+
+### The directives that are not obvious
+
+- **`script-src 'self'`** — no `'unsafe-inline'`, no `'unsafe-eval'`. This is
+ the whole point of the exercise. It cost one change: the theme bootstrap that
+ used to sit inline in `index.html` now lives in `public/theme-bootstrap.js`
+ ([12](12-theming.md)). `src/` contains no `eval` and no `new Function`.
+- **`style-src 'unsafe-inline'`** — forced, and deliberate. The **binding**
+ constraint is CodeMirror, not our own components: `@codemirror/view` mounts
+ its theme through `style-mod`'s `StyleModule.mount`, which for a document
+ root — every mount in this app, since nothing here uses shadow DOM — creates
+ a `