1The problem in 30 seconds
Three symptoms all read as "the session keeps closing" β but they are distinct bugs, and the audit found the shared systemic root behind them.
F5 logs you out in QA/prod #553 Β· root
Frontend and API on different hosts β the host-only CSRF cookie isn't JS-readable β refresh omits X-CSRF-Token β 401 csrf-mismatch β logout. Works locally only because ports share localhost.
RefreshCookieWriter.cs:28-39 Β· RefreshTokenEndpoint.cs:65-71
F5 logs you out in local/dev #554
Admin store is in-memory only; portal persists to sessionStorage in DEV. On F5 the admin depends 100% on the boot refresh.
Support (handoff) session dies in ~15 min ADR-0064
ExchangeHandoff issues only a 15-min JWT, no refresh cookie (verified: it injects neither SessionIssuer nor RefreshCookieWriter).
2Audit verdict β solid base, broken edges
Four parallel audits (token engine Β· all auth endpoints Β· JWT validation + tenant isolation Β· frontend both apps) verified the real code on main. The base is fundamentally solid β no rewrite.
β Solid & verified (don't regress)
- Rotation atomicity (real transaction) + reuse detection with 5s grace.
- 256-bit tokens, SHA-256, unique
TokenHashindex; sliding+absolute expiry. - 4-layer tenant isolation:
midonly from signed JWT, EF filters + RLS + composite FK + physical DB. - Power-user path re-validates actor active per request.
- CORS locked + prod fail-fast; magic-link + OAuth (PKCE, state) sound.
- Session mgmt endpoints + admin sessions page already exist.
π΄ Broken β must fix (by root cause)
- G1 (systemic): refresh drops
mid/context βRotateAsyncβIssueAsync(user)re-derives from scratch. Breaks handoff, ActivateMerchant, multi-merchant. - G2: handoff + ActivateMerchant don't issue/rotate a full session.
- G3: cross-host cookie (#553).
- G4: throttle + CSRF gaps; HTTPS/HSTS; ClockSkew/issuer/audience/alg.
- G5: adminβportal frontend inconsistency; multi-tab refresh race.
- G6: merchant picker unpaginated; duplicated ReasonModal; portal has no sessions page.
- G7: dead code / stale comments / AI
int.TryParseon PublicId.
RefreshToken) is the source of truth for context, and refresh reconstructs the JWT from it while re-validating authorization.3How a session works (the model)
Standard short access token + rotating refresh token. The JWT never touches disk; the refresh lives in an HttpOnly cookie JS can't read.
| Piece | Lifetime | Where | Purpose |
|---|---|---|---|
| Access JWT | 15 min | Memory | Authorizes each API call |
| Refresh token | 30 min sliding | __Secure-qiiub_rt (HttpOnly) | Renews JWT; rotates every use |
| CSRF nonce | = refresh | __Secure-qiiub_csrf (JS-readable) | Double-submit anti-CSRF |
| Absolute cap | 12h β 8h prop. | RefreshToken row | Hard ceiling |
SessionId; a new tab refreshes into the same one. 5 tabs = 1 session.4The correct architecture: same-origin (BFF)
OWASP: omit Domain (host-only cookies), use __Host-. So the fix isn't shared-domain cookies β it's making the frontend and its API share an origin.
admin.qa.qiiub.comapi.qa.qiiub.comadmin.qa.qiiub.comadmin.qa.qiiub.com/apiWhy, not shared-domain
- Keeps host-only cookies (audited model).
- Isolates adminβportal (a portal XSS can't touch admin).
- The recognized BFF pattern.
Where
/api β backend.5The F5 (reload) flow
6Multiple tabs β the race & the fix
5 tabs share one session (good). But the single-flight guard is per-tab: at ~13 min (or on laptop wake) all tabs refresh at once β the 5s grace usually saves it, but a slip trips reuse detection β whole chain revoked β all tabs to /login. That's the "random" logout.
7Two access modes β "Your merchants" vs "Support"
A power user reaches a merchant in two distinct modes that must not be conflated. The /select-merchant picker shows them as two sections.
| Your merchants | Support access | |
|---|---|---|
| Basis | real UserMerchant membership | power-user role, no membership (ADR-0064) |
| Session | Normal (8h) | Support (1h) |
| Reason modal | No | Yes (required, audited) |
| Audit | Normal | SupportSessionStarted |
DependencyInjection.cs:129); multi-DB routing ("12β16 DBs") is designed but not built. So the QA merchant lives in the shared DB, isolated by MerchantId + RLS β exactly the isolation the tests should exercise. A physically separate DB is a future epic (routing), not needed here. Login never sees the DB; it discerns membership via UserMerchant.8Handoff / impersonation β the fix
Scope-access, not "login as": identity kept (sub), mid added, audited. The flow works; the session lifecycle is what's broken.
ExchangeHandoff issues the JWT but writes no refresh cookie β dies in 15 min.The fix
Exchange issues a full, independent session (refresh chain scoped to mid), with the 1h support cap.
Cap: 1 hour
Elevated cross-tenant access β tight window. If support needs longer, re-enter with a fresh reason (better audit). Idle-30m already ends forgotten support sessions.
9Timeouts β OWASP-aligned, configurable
| Timeout | Today | Proposed | OWASP |
|---|---|---|---|
| Access JWT | 15 min | 15 min β | short β standard |
| Idle | 30 min | 30 min (config) | 15β30 min |
| Absolute (prod, normal) | 12h | 8h | 4β8h workday |
| Absolute (QA, normal) | 12h | 8β12h | QA uses normal membership |
| Support session | β | 1h | elevated β tight |
Timeouts move to per-environment config with a hard server-side ceiling prod can't exceed, and lifetime becomes a per-issuance parameter β the guardrail that makes "Remember me" additive later.
Close & reopen the browser
The refresh cookie is persistent, so it survives a restart. Reopen within ~30 min β auto signed-in; after β re-login. (Deliberate for a money app.)
"Remember me" (future)
Off by default; opt-in, separate revocable longer-lived session. Never for support sessions. Salesforce/Auth0 model β see Β§16.
10QA direct login & the scalable picker
QA logs directly into the portal (Microsoft Entra) and lands on a filterable picker. Two things make it work at scale:
Server-side search + pagination
Today /auth/merchants returns all merchants and filters client-side β breaks at 500β1000. Fix: server-side search + pagination together (debounced typeahead; empty query β first page).
ListUserMerchantsEndpoint.cs:101-120 Β· MerchantPicker.tsx:53-61
Membership, not support-sim
QA gets a seeded membership on the QA test merchant (Β§7) β "Your merchants", normal session. No 1h cap, no reason, no audit noise.
11Threat model β can the session be stolen?
| Attack | Copy the session? | Why / control |
|---|---|---|
| Read refresh token via XSS/JS | No | HttpOnly β JS can't read it; XSS can't exfiltrate it. |
| Abuse in-memory JWT via XSS | While script runs | Inherent to SPAs. Mitigated by CSP, SAST, same-origin isolation. No portable copy (no refresh cookie). |
| Sniff the network | No | Secure + HTTPS everywhere. |
| Copy cookies (malware/device theft) | Possible, but detected | Rotation + reuse detection revoke the whole chain on replay; absolute cap bounds it. |
| Cross-site forgery (CSRF) | No | SameSite=Strict + double-submit nonce (kept even same-origin). |
| Read the CSRF cookie | Useless alone | Just a nonce; worthless without the HttpOnly refresh. |
__Host- option, memory-only broadcast.12Security invariants & guardrails
Must stay true (don't regress)
HttpOnly+Secure+SameSite=Strict+Path=/api/auth+__Host-.mid/pid only from the signed JWT.MerchantId first filter; RLS per connection.New guardrails (implement correctly)
mid (Β§8).SameSite=Strict + CSRF even under same-origin.13What already exists (don't rebuild)
exists Expiry warning modal
SessionWarningDialog + useIdleTimer in both apps (30m idle β 2m countdown β "Stay signed in", cross-tab synced). Not seen today only because the bugs kill the session first.
exists Sessions & merchant switcher
Admin /profile/sessions (list/revoke/logout-all) + MerchantPicker + /select-merchant + TopNav switcher. Portal lacks the sessions page β ported in P6.
14Phases P0βP6
7 phases β P0 is groundwork (ADR + threat model + test harness; no behavior change), P1βP6 are the six build phases. P1 is the gate (highlighted): nothing after it is correct until context-on-refresh is fixed.
__Host- cookie. Fixes #553/#554.ReasonModal in @qiiub/ui; two-section picker; server-side search+pagination; QA membership.Sequencing: P0 β P1 β P2 β (P3+P4) β P5 β P6. P1 is the gate β nothing after it is correct until context-on-refresh is fixed.
Future (separate ADRs)
Remember-me (guardrail set in P1) Β· MFA/step-up for sensitive actions Β· new-device email alerts Β· device/IP binding Β· in-app admin session-config UI.
15Decisions (resolved 2026-07-02)
16References & companion artifacts
Standards
- OWASP Session Management (timeout ranges, server-side).
- OWASP Cookie Attributes (omit Domain,
__Host-). - OWASP Authentication (remember-me = separate token).
- BCPOS
security-auditSAST (Mike) β STRIDE + multi-tenant.
Remember-me vendors
- Salesforce β timeout still governs; admin-disable.
- Google/Microsoft β long session + risk-based step-up.
- Auth0 β separate cookie, off by default.
- Banking/POS β usually none.
session-security-design.md (spec/decisions) Β· session-security-plan.md (phased plan) Β· this HTML (technical explainer) Β· token-security-explained.html (friendly primer β public help). When ported: this becomes docs/design/session-security.md + docs/html/, Β§11 becomes docs/security/session-threat-model.md.17Glossary
| Term | Definition |
|---|---|
| Access token (JWT) | Short-lived signed token proving identity per call; in memory only. 15 min (glossary drift: repo says "30 min" β that's the refresh sliding window). |
| BFF / same-origin proxy | API served under the frontend host (/api) so cookies stay host-only and CSRF works. |
| Bootstrap / rehydrate | On load/F5 the app silently calls /auth/refresh to restore the session before routing to /login. |
| Cookie prefixes | __Secure- requires HTTPS; __Host- also forbids Domain + requires Path=/ β strictest, unlocked by same-origin. |
| CSRF nonce / SameSite | Random per-session value echoed in a header (double-submit) + SameSite=Strict β blocks cross-site forgery. |
| Handoff / scope-access | ADR-0064 power-user merchant entry: identity kept, mid added β not "login as". |
| Leader election (Web Locks) | One tab holds a browser lock and refreshes for all, broadcasting the JWT; auto-released if it closes. |
| Refresh token | Opaque single-use token in an HttpOnly cookie; exchanged at /auth/refresh for a fresh JWT. |
| Rotation / reuse detection | Each refresh revokes the old token; replaying a rotated token (past 5s grace) revokes the whole chain. |
Session / SessionId | One continuous sign-in β a chain of rotating refresh tokens. Created only by a real login, not a new tab. |
| Sliding vs absolute | Sliding (30m) extends on use; absolute is the hard ceiling regardless of activity. |
| STRIDE | Microsoft's threat-modeling framework β six categories you walk each critical flow against: Spoofing (identity), Tampering (integrity), Repudiation (deniability), Information disclosure (confidentiality), Denial of service, Elevation of privilege. The full STRIDE-per-flow pass lives in the design spec (β docs/security/session-threat-model.md); Β§11 here shows its session-theft result. It catches design-level flaws a code audit misses. |