---
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 `<App/>` 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.
