--- key: security.session-cutover-postify title: '2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification' status: active since: '2026-07-12' supersedes: none superseded-by: none rule: 'The browser SPA authenticates cookie-only (no more `X-Api-Key` from `web/`); the machine key is repurposed to external/MCP-only via `GET /api/auth/machine-key`; every side-effecting GET/HEAD under `/api` is converted to POST so the existing CSRF gate covers it (standing rule: never add a side-effecting GET/HEAD under `/api`).' signals: 'SPA cookie-only cutover, CSRF POST-ification, AuthGate boot flow · paths: `web/src/api/client.ts`, `web/src/AuthGate.tsx` · issues: #295, #301, #197' mechanics: '`docs/spa-conventions.md` §5e; `docs/api-conventions.md` §9; `docs/e2e-local.md`' --- PR1 shipped the server side (previous entry): `/api` accepts a session cookie OR the machine `X-Api-Key`, with `X-CSRF` required on session-authenticated mutations. **PR2 is the SPA cutover** — the browser now authenticates with the session only — plus **#301** (a session-cookie CSRF hole in side-effecting GETs). **Browser is cookie-only; the machine key is external/MCP-only.** `web/src/api/client.ts` no longer attaches `X-Api-Key`; it relies on the same-origin session cookie and sets `X-Csrf: '1'` on every mutating verb centrally. The former "paste your key" `ApiKeyScreen` is repurposed to **machine-key management**: it reads the server key from the new `GET /api/auth/machine-key` (session-gated; masked with Reveal + Copy) so an operator can hand it to MCP / external REST clients — the browser itself never sends it again. *Why:* one credential per audience (the ratified #295 model); leaving a browser key path alive would keep a CSRF-immune bypass around and defeat the point. **Boot gate, not a route** (`web/src/AuthGate.tsx`, wrapping `` in `main.tsx`): on load it calls the public `GET /api/auth/config` then `GET /api/auth/session` and renders Setup (first-run local-admin claim) / Login (local form + an OIDC "Sign in with SSO" button when `oidcEnabled`) / the app. Login and Setup mint **no URL** — the gate renders them at whatever `/app/*` path was requested, so a deep link survives login for free and **no `blazor-route-parity.md`/`domain-model.md` route rows are added**. It publishes `AuthContext` (`{ username, method, signOut, requireLogin }`); the 401 signal (`notifyUnauthorized`) now drives re-login via a passive shell banner (never yanks a dirty draft — it consults the navigation guard first). Auth flows that expect a 401 inline (login, change-password) pass `suppressUnauthorizedSignal`. **#301 — POST-ify, don't gate-the-GET.** A side-effecting GET is a CSRF vector once a `SameSite=Lax` cookie is a normal credential (it rides a cross-site top-level navigation). The three offenders became mutating verbs so the existing filter CSRF gate covers them with zero new machinery: `GET /api/troubleshoot/playback.m3u8` → **`POST /api/troubleshoot/playback/start`** returning `200 { url }` (the open `/iptv` manifest the player then loads — so hls.js/native-HLS needs no header injection, strictly better than X-CSRF-on-GET); the archive and sample GETs → **POST** (SPA downloads them via a fetch-blob helper, never `window.open`). Removing the HEAD variants also fixed a latent bug: a HEAD opened the `DeleteOnClose` stream and destroyed the artifact. Standing rule added to `api-conventions.md §9`: **never add a side-effecting GET/HEAD under `/api`.** **Machine-key GET discloses the key to any authenticated session** — deliberate: the session principal is the single admin (local or OIDC), same-origin policy blocks a cross-site page from reading the response body, and it is how the "copy the key for MCP" UX works without a rotation endpoint (rotation is a later PR). **Accepted residual (OIDC logout):** `POST /api/auth/logout` ends the *app* cookie but not the IdP session, so an OIDC user who clicks "Sign out" then "Sign in with SSO" returns without re-entering credentials — a `returnUrl`/RP-initiated logout is a future nicety. Docs: `spa-conventions.md §5e` (SPA seams), `api-conventions.md §9`, `e2e-local.md` (browser setup/login flow). Refs #295 #301 #197.