ADR-0015 — Frontend auth transport: cookie-on-upgrade, principal-bound session authz, reject the bundled token¶
Status: Accepted · Implementation: shipped 2026-06-15 · Date: 2026-06-15
Context¶
The coach SPA authenticates to /live with a single shared LIVE_TOKEN baked into the JS bundle
(VITE_LIVE_TOKEN, client/src/config.ts) and sent in the WebSocket URL
query string (client/src/useLiveTelemetry.ts). For a children's-
location feed that is a long-lived bearer secret readable in the bundle and leaked into Caddy/server access
logs, browser history, and Referer. ADR-0008 already commits to
argon2id login + HTTP-only session cookies but tagged it deferred/Profile-B. Browsers cannot set custom
headers on new WebSocket() (no Authorization), so a cookie auto-attached on the upgrade GET is the only
clean browser-WS auth — but only when the client is same-origin with the WS endpoint. The FE panel's
adversarial pass flagged that "the cookie is sent on the upgrade" is a topology constraint, not a given, and
that a per-session token still in the bundle would be security theatre. Today server.ts open() only checks
that a sessionId is present — it subscribes to whatever session string the client supplies.
Decision¶
- Reject any bundle-baked token. No secret in
import.meta.env/ the WS query string; stop shippingLIVE_TOKENin client JS. - AuthN before authz. Implement the ADR-0008 named login
(argon2id + HTTP-only/Secure/
SameSite=Laxsession cookie; hand-rolled per the minimal-dependency ethos — no auth SDK/IdP). On the isolated LAN, the single default coach account ADR-0008 specifies. - Cookie on the WS upgrade — same-origin required (hard constraint). The client MUST be served from the
relay/Caddy origin so the browser auto-attaches the session cookie on the
wss://…/liveupgrade. Recorded here as a deployment constraint, not an assumption. - Dev parity via a same-origin proxy. Vite serves
:5173and proxies/live(+ the API) to the server so dev is same-origin; the current cross-originws://localhost:3000default is removed. A dev-only env token, gated to non-production, is the only fallback if a same-origin dev proxy is not used. - Session authz bound to the principal. Replace the presence-only
sessionIdcheck inserver.tsopen()with "is this authenticated principal assigned to thissessionId?"; reject1008and countft_ws_rejected{reason="not_authorized_for_session"}. - Origin allow-list stays. Cookies do not stop CSWSH; keep
server.tsoriginOk()as the CSWSH defence (critical under anySameSite=Nonesplit-host deploy). - Cross-origin escape hatch: a short-lived, single-use WS ticket. If a split-host deploy is ever required, mint a short-lived single-use ticket via an authenticated request and pass it once on the upgrade — never a long-lived token in the bundle.
Implementation (shipped 2026-06-15)¶
Built in parallel streams against the frozen contract, which the
4-lens adversarial security pre-mortem hardened before any code was written. What landed:
- Cookie on the upgrade. Login mints an HttpOnly/SameSite=Lax/Path=/ session cookie — __Host-ft_session
when Secure, ft_session otherwise — that the browser auto-attaches to the same-origin /live upgrade GET
(server/src/auth.ts setCookieHeader/COOKIE_NAME).
- Bundled token killed. LIVE_TOKEN / VITE_LIVE_TOKEN and the ?token= WS query param are gone; no secret
ships in import.meta.env or any URL (client/src/config.ts is now token-free).
- Principal-bound session authz. server.ts open() resolves the cookie → principal → live accounts map and
rejects (1008 'forbidden session') when the principal is not assigned to the requested session, counting
ft_ws_rejected_total{reason="not_authorized_for_session"}.
- Vite same-origin proxy. vite.config.ts proxies /live (ws:true), /auth, and /sessions to
VITE_PROXY_TARGET with changeOrigin:false, so dev is same-origin and the browser Origin reaches the
server's strict allow-list.
- CSP connect-src 'self'. Same-origin WS + fetch + HMR; the build-time __WS_CONNECT_SRC__ placeholder and
its derived-host plugin are removed.
Pre-mortem hardening¶
The adversarial pass folded these must-fixes in (all in server/src/auth.ts /
server/src/server.ts): server-side logout revocation — logout deletes the
token, so a captured cookie cannot be replayed; strict Origin — an absent Origin is rejected on both
/auth and /live (the old lenient "no Origin → ok" branch would have let any header-omitting curl bypass the
CSWSH/CSRF layer); anon scoped to ANON_SESSIONS, never wildcard — the isolated-LAN bypass reads only its
listed sessions; argon2id login DoS controls that run-and-reject before any hash — a per-IP token bucket, a
global concurrent-hash cap, a per-username soft-lock, and a 4 KB body cap — plus a constant-work dummy hash
on unknown users so there is no enumeration timing oracle; periodic accounts reload as the revocation path —
edits/removals take effect within AUTH_ACCOUNTS_RELOAD_SECONDS, dropping orphaned sessions and closing the
now-unauthorized principal's open /live sockets; and a CSRF synchronizer token delivered in the response
body (never a readable cookie) and compared constant-time against the stored value on state-changing routes.
Consequences¶
- + Kills the headline risk: one leaked bundle secret can no longer read every child's live location across all sessions; per-session authz contains blast radius.
- + Token out of URLs → no leakage via logs/history/
Referer. - + Reuses ADR-0008's committed design; no new heavyweight dependency (driver #3).
- − Couples the client deploy to the relay origin (documented hard constraint) and adds a Vite dev proxy.
- − Requires the server login + session-authz endpoints (cross-cutting; tracked in the FE plan).
Alternatives considered¶
- Per-session token still in the bundle — rejected: theatre; readable by anyone who loads the app.
Authorization: Beareron the WS — impossible from a browser WebSocket (no custom headers).- Token in the query string (status quo) — rejected: leaks into logs/history/
Referer. - OAuth / external IdP — already rejected by ADR-0008 (overkill for ~20 users; violates the cost driver).