Why
Today the dashboard chat panel keeps API keys in browser localStorage and POSTs them to the forwarder on every chat request. After the per-profile localStorage fix (PR for issue #497-ish followup), the operator no longer has to re-paste a key when switching providers — but the keys still live plaintext on disk in browser storage, scoped only to the dashboard's origin. Long-term, taking the keys out of the browser entirely is the more defensible posture:
- XSS on the dashboard's origin = key exfil
- Anyone with local file access can read
~/Library/Application Support/<browser>/.../Local Storage/...
- Keys can leak to browser sync extensions / sync engines
- The dashboard chat is the only "BYO key in browser" surface in our stack; everywhere else (Claude Code, harness CLI, scripts) reads from
$ANTHROPIC_API_KEY / $HF_TOKEN / etc., which can be backed by macOS Keychain. This issue brings the dashboard chat in line.
What
Move API keys out of localStorage and into a server-side store keyed by an HTTP session cookie. Browser sends Cookie: chat_session=<id> on every chat request; forwarder looks up the appropriate per-profile key for that session.
Architecture
Browser Forwarder (Docker on test-dev)
───────── ───────────────────────────────
ChatSettings UI POST /api/v2/chat/keys
paste key once ───────► body: {profile, api_key}
stores in server-side memory keyed
by (cookie_session_id, profile);
returns Set-Cookie if first time.
ChatPanel (every turn) POST /api/v2/chat
Cookie: session=xyz ──► looks up api_key for
body: {profile, msgs} (cookie_session_id, profile);
forwards to upstream as before.
No api_key field in browser →
forwarder request bodies.
Storage choice (the biggest trade-off)
| Storage |
Persistence |
Security |
When to use |
| a. In-memory only (sync.Map keyed by session_id) |
Lost on forwarder restart — re-paste once per restart |
Best: keys never touch disk |
The "I trust the network, just don't want it in the browser" case |
b. Plain file on bind-mounted dir (/claude/keys/<session>.json) |
Survives restarts |
Equivalent to keys in ~/.aws/credentials |
If forwarder restarts annoy more than disk plaintext does |
| c. Encrypted file with operator master password |
Survives restarts; user types master password after forwarder restart |
Better than plain file; worse UX |
If actual untrusted local access matters |
| d. macOS Keychain via SSH bridge |
Survives anything |
Best |
Over-engineered for one user on one box |
Recommended: Option (a) in-memory only for v1. If RAM-restart-friction becomes annoying, upgrade to (b) — same code, swap the backing store.
Auth (the second-biggest trade-off)
Today /api/v2/chat is open to anyone on the LAN. Server-side keys make that worse — anyone reaching the forwarder can USE your Anthropic credit.
| Auth |
Effort |
Security |
| i. None (single-user trust) |
0 |
Anyone on the LAN can spend your keys. Adequate for localhost-only dev; not for shared LAN. |
| ii. Simple HMAC-signed cookie issued on key-paste |
2-3 hours |
A leaked cookie = key access. Cookie scoped to the session; new cookie per key-paste. Reasonable for single-operator dev. |
iii. Extend the existing INFINITE_STREAM_AUTH_HTPASSWD to cover /api/v2/chat |
1 hour |
HTTP Basic auth gate. Operator's Basic password becomes the key to the keys. Already used for /dashboard/, /analytics/api/, /grafana/. |
Recommended: Option (iii) because it reuses an auth mechanism already in place for dashboard / Grafana / analytics paths. Today, chat endpoints are explicitly excluded from the htpasswd gate (per CLAUDE.md: "player-app endpoints stay public"). We'd add /api/v2/chat/keys (and /api/v2/chat itself, when server-side keys are configured) to the gated set.
Server-side endpoints
POST /api/v2/chat/keys — body { profile, api_key }. Stores in server-side memory keyed by (session_id, profile). Issues a session cookie on first request.
GET /api/v2/chat/keys — returns { profiles_with_keys: ["anthropic-claude", "huggingface"] }. Never returns key values.
DELETE /api/v2/chat/keys/{profile} — revoke one profile's stored key for this session.
handleChat lookup: if request body has api_key, use it (backward compat); else look up (session_id, profile) from cookie + body; else 400 "no key configured for this profile".
Browser-side changes
useChatSettings: drop the apiKey field from the public ref. keys map goes away entirely (server holds them now).
ChatSettings.vue: pasting a key calls POST /api/v2/chat/keys immediately (not just localStorage write); shows ✓/× per profile via GET /api/v2/chat/keys.
useChat: stop sending api_key in the request body — forwarder looks it up from the cookie.
- A new "Configured providers" widget in chat settings shows which profiles have stored keys + a "Revoke" button per profile.
What this issue does NOT ship
- macOS Keychain integration for the forwarder. The forwarder runs in Docker on a different machine; Keychain isn't reachable. If we ever want Keychain-backed storage, it'd be a separate "Mac-side key helper" daemon that the forwarder calls (option d above — significant work).
- Multi-user identity — sessions are anonymous (cookie ≠ user); HTTP Basic gives a single shared password gate.
- Rotation / expiry policy — keys live until explicit revocation or forwarder restart. Worth revisiting if usage shows mistakes.
Acceptance
POST /api/v2/chat/keys accepts and stores a key in server memory; subsequent POST /api/v2/chat requests look it up via cookie.
localStorage for the chat panel no longer contains an api_key field after migration; the keys map is removed.
- Forwarder restart → all stored keys are gone; user re-pastes once per profile they want to use.
- HTTP Basic gate on
/api/v2/chat/keys (and optionally on /api/v2/chat itself) when INFINITE_STREAM_AUTH_HTPASSWD is configured.
- Backwards-compat: requests that still include
api_key in the body continue to work for one release cycle (so old browser tabs don't break mid-session).
Out-of-scope follow-ups (if anyone asks)
- True multi-user auth (OAuth / SSO) — different design, different problem
- Encrypted-at-rest storage option (encrypted file with master password) — straightforward upgrade from in-memory if needed
- Keychain-via-helper daemon for storing keys on the Mac and serving to remote forwarders — only worth it if "forwarder restart loses all my keys" becomes annoying enough
Why
Today the dashboard chat panel keeps API keys in browser
localStorageand POSTs them to the forwarder on every chat request. After the per-profile localStorage fix (PR for issue #497-ish followup), the operator no longer has to re-paste a key when switching providers — but the keys still live plaintext on disk in browser storage, scoped only to the dashboard's origin. Long-term, taking the keys out of the browser entirely is the more defensible posture:~/Library/Application Support/<browser>/.../Local Storage/...$ANTHROPIC_API_KEY/$HF_TOKEN/ etc., which can be backed by macOS Keychain. This issue brings the dashboard chat in line.What
Move API keys out of
localStorageand into a server-side store keyed by an HTTP session cookie. Browser sendsCookie: chat_session=<id>on every chat request; forwarder looks up the appropriate per-profile key for that session.Architecture
Storage choice (the biggest trade-off)
/claude/keys/<session>.json)~/.aws/credentialsRecommended: Option (a) in-memory only for v1. If RAM-restart-friction becomes annoying, upgrade to (b) — same code, swap the backing store.
Auth (the second-biggest trade-off)
Today
/api/v2/chatis open to anyone on the LAN. Server-side keys make that worse — anyone reaching the forwarder can USE your Anthropic credit.localhost-only dev; not for shared LAN.INFINITE_STREAM_AUTH_HTPASSWDto cover/api/v2/chat/dashboard/,/analytics/api/,/grafana/.Recommended: Option (iii) because it reuses an auth mechanism already in place for dashboard / Grafana / analytics paths. Today, chat endpoints are explicitly excluded from the htpasswd gate (per
CLAUDE.md: "player-app endpoints stay public"). We'd add/api/v2/chat/keys(and/api/v2/chatitself, when server-side keys are configured) to the gated set.Server-side endpoints
POST /api/v2/chat/keys— body{ profile, api_key }. Stores in server-side memory keyed by (session_id, profile). Issues a session cookie on first request.GET /api/v2/chat/keys— returns{ profiles_with_keys: ["anthropic-claude", "huggingface"] }. Never returns key values.DELETE /api/v2/chat/keys/{profile}— revoke one profile's stored key for this session.handleChatlookup: if request body hasapi_key, use it (backward compat); else look up(session_id, profile)from cookie + body; else 400 "no key configured for this profile".Browser-side changes
useChatSettings: drop theapiKeyfield from the public ref.keysmap goes away entirely (server holds them now).ChatSettings.vue: pasting a key callsPOST /api/v2/chat/keysimmediately (not just localStorage write); shows ✓/× per profile viaGET /api/v2/chat/keys.useChat: stop sendingapi_keyin the request body — forwarder looks it up from the cookie.What this issue does NOT ship
Acceptance
POST /api/v2/chat/keysaccepts and stores a key in server memory; subsequentPOST /api/v2/chatrequests look it up via cookie.localStoragefor the chat panel no longer contains an api_key field after migration; the keys map is removed./api/v2/chat/keys(and optionally on/api/v2/chatitself) whenINFINITE_STREAM_AUTH_HTPASSWDis configured.api_keyin the body continue to work for one release cycle (so old browser tabs don't break mid-session).Out-of-scope follow-ups (if anyone asks)