# Live auth security verification Date: 2026-08-04 Environment: local Descrybe v2 (API http://127.0.0.1:8080, web http://127.0.0.1:5174) Demo credentials: see [demo-user.md](demo-user.md) (local only). ## Scope Live checks against a running API + web app: 1. Login / logout (session cookie) 2. CSRF rejection when `X-CSRF-Token` is missing or wrong 3. API key auth (`Authorization: Bearer` and `X-API-Key`) 4. Invite flow without incorrectly leaking tokens in the UI/HTML Related static notes: [security-notes.md](security-notes.md). ## Results (2026-08-04) **23 / 23 checks passed** after the accept-invite UI fix below. | Area | Result | Notes | |------|--------|-------| | CSRF cookie issued on GET under CSRF middleware | PASS | `descrybe_csrf`, 32 hex chars, not HttpOnly (readable by JS for double-submit) | | POST `/api/auth/login` without CSRF header | PASS | HTTP 403 `csrf token mismatch` | | POST `/api/team/invites` without CSRF (authenticated) | PASS | HTTP 403 | | POST `/api/auth/logout` without CSRF | PASS | HTTP 403 | | Login bad password | PASS | HTTP 401 | | Login demo user + CSRF | PASS | HTTP 200; sets `descrybe_session` HttpOnly | | GET `/api/auth/me` after login | PASS | Session identity returned | | Logout with CSRF | PASS | HTTP 200; subsequent `/api/auth/me` returns 401 | | Create invite (SMTP off) | PASS | `mail_sent=false`; token returned once in create response | | GET `/api/team/invites` | PASS | Pending list has no `token` field | | GET `/api/api-keys` | PASS | Prefix only; no plaintext `dk_` secret | | `Authorization: Bearer` demo key to `/api/v1/products` | PASS | HTTP 200 | | `X-API-Key` demo key | PASS | HTTP 200 | | Bad / missing API key | PASS | HTTP 401 | | `/api/v1/*` skips cookie CSRF | PASS | Key auth only | | Accept-invite page with `?token=` | PASS | Token not in HTML; no visible token field; shows Invite link recognized | | Accept invite + reuse | PASS | Accept 200; second accept 400 | ### Harness note PowerShell `WebRequestSession.Headers` persists `X-CSRF-Token` across calls. CSRF-negative tests must call `$session.Headers.Clear()` (or use a client that does not cache custom headers). Without clearing, a missing-CSRF probe can false-pass. ## Fix applied **Problem:** `/accept-invite?token=...` bound the secret into a visible `type="text"` input (reflected into the DOM). That is incorrect for email-link acceptance. **Change:** `apps/web/src/routes/accept-invite/+page.svelte` - If `token` is present in the query string: keep it only in JS state, show a link-recognized message (no token value in HTML), and `history.replaceState` to strip `token` from the address bar. - If no query token: show a masked `type="password"` paste field with `autocomplete="off"`. - Settings: `autocomplete="off"` on one-time invite accept-link and new API key display fields. Also confirmed already correct: - Create-invite returns `token` only when mail was not delivered (`inviteMailResult`). - List invites / list API keys never return full secrets. - New API key plaintext is shown once in a dismissible dialog; table shows `key_prefix` only. ## How to re-run (manual) ```powershell # From repo root with API on :8080 and web on :5174 $base = "http://127.0.0.1:8080" $s = New-Object Microsoft.PowerShell.Commands.WebRequestSession try { Invoke-WebRequest "$base/api/auth/me" -WebSession $s -UseBasicParsing | Out-Null } catch {} $csrf = ($s.Cookies.GetCookies([uri]$base) | ? Name -eq descrybe_csrf).Value # Expect 403 $s.Headers.Clear() try { Invoke-WebRequest "$base/api/auth/login" -Method POST -WebSession $s -ContentType "application/json" -Body '{"email":"x","password":"y"}' -UseBasicParsing } catch { [int]$_.Exception.Response.StatusCode } # Login Invoke-WebRequest "$base/api/auth/login" -Method POST -WebSession $s -Headers @{"X-CSRF-Token"=$csrf} ` -ContentType "application/json" -Body '{"email":"demo@descrybe.local","password":"DemoPass123!"}' -UseBasicParsing # API key Invoke-WebRequest "$base/api/v1/products?limit=1" -Headers @{ Authorization = "Bearer dk_demo_local_descrybe_test_key_v1" } -UseBasicParsing # Accept-invite HTML must not contain the probe token $tok = "LIVESEC_TOKEN_SHOULD_NOT_SSR_LEAK_ABCDEF" $html = (Invoke-WebRequest "http://127.0.0.1:5174/accept-invite?token=$tok" -UseBasicParsing).Content $html.Contains($tok) # expect False $html -match "Invite token" # expect False when ?token= present ``` Unit coverage (no live server): ```bash cd apps/api && go test ./internal/httpapi/ -run "CSRF|InviteMail" -count=1 ``` Note: as of this run, `go test ./internal/httpapi` failed to compile due to an unrelated missing `strconv` import in `internal/shopify/orders_sync.go`. Live HTTP checks above are the source of truth for this doc. ## Residual risks 1. Invite/reset links still use `?token=` -- the first request URL (and possibly reverse-proxy access logs) can contain the secret until the client strips it. Prefer `#token=` or a short-lived exchange code for a future hardening pass. 2. Operator one-time accept link on Settings (when SMTP is off) intentionally shows the full URL once so it can be copied -- dismiss after sharing; do not leave the tab open on a shared screen. 3. Demo API key / password in [demo-user.md](demo-user.md) are local-only; rotate or disable before shared staging. 4. In-process CSRF/session controls are not a substitute for HTTPS + `SessionSecure=true` in production. 5. **No self-serve forgot-password.** Password recovery for established accounts is operator/admin (migration set-password re-issue only). Do not relax `must_set_password` / `SetPassword` for public reset — see [forgot-password.md](forgot-password.md). ## Contract preserved - Dashboard `/api/*` (non-v1): session cookie + CSRF double-submit (`X-CSRF-Token` equals `descrybe_csrf`). - Public `/api/v1/*`: API key only; CSRF skipped. - Invite plaintext token: create response only when undelivered; never on list. - API key plaintext: create response only; list returns `key_prefix`.