Wiki πŸ” Search
QIIUB

Session Security β€” Design & Audit

The full picture behind issues #554 / #553 and the ADR-0064 support flow: what a four-front code audit found, and how the session-security foundation is made solid, complete, and secure to build on.

#554 Β· F5 (dev) #553 Β· CSRF cross-host ADR-0064 Β· support Audit verdict Phases P0–P6
Verdict: the base is fundamentally solid β€” no core rewrite. A coherent set of broken edges (G1–G7) must be fixed, led by one systemic bug: the session doesn't durably keep its context across refresh. Validated against current main (2026-07-02).

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).

Plus: a multi-tab refresh race can trip reuse-detection β†’ "random" logout (Β§6). And the audit surfaced the real root that ties several of these together β†’ Β§2.

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 TokenHash index; sliding+absolute expiry.
  • 4-layer tenant isolation: mid only 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.TryParse on PublicId.
The linchpin is G1. The active merchant/context lives only in the ephemeral JWT; refresh rebuilds it from scratch and loses it. Fix: the session (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.

PieceLifetimeWherePurpose
Access JWT15 minMemoryAuthorizes each API call
Refresh token30 min sliding__Secure-qiiub_rt (HttpOnly)Renews JWT; rotates every use
CSRF nonce= refresh__Secure-qiiub_csrf (JS-readable)Double-submit anti-CSRF
Absolute cap12h β†’ 8h prop.RefreshToken rowHard ceiling
One session β‰  one tab. Only a real login mints a new 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.

βœ— TODAY (QA) β€” different hosts
SPA Β· admin.qa.qiiub.com
βœ• CSRF cookie can't cross
API Β· api.qa.qiiub.com
βœ“ PROPOSED β€” same-origin
SPA Β· admin.qa.qiiub.com
API under admin.qa.qiiub.com/api

Why, not shared-domain

  • Keeps host-only cookies (audited model).
  • Isolates admin↔portal (a portal XSS can't touch admin).
  • The recognized BFF pattern.

Where

QA/prodReverse proxy: /api β†’ backend.
LocalVite dev proxy β†’ fixes #554 at the root.

5The F5 (reload) flow

F5
store boots empty
β†’
bootstrap
read CSRF, call /auth/refresh
β†’
rotate + issue
same SessionId
β†’
setAuth
restored
β†’
still in
no /login flash
Breaks today (QA): "read CSRF" returns null (other host) β†’ 401 β†’ /login. Β§4 makes that step work.

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.

Fix: a Web-Locks leader does the refresh and broadcasts the new JWT to the others. Leader tab closes? The browser auto-releases the lock β†’ the next tab takes over; and any tab can self-refresh as a fallback. A dying leader is, worst case, a momentary fall back to today's behavior β€” never a 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 merchantsSupport access
Basisreal UserMerchant membershippower-user role, no membership (ADR-0064)
SessionNormal (8h)Support (1h)
Reason modalNoYes (required, audited)
AuditNormalSupportSessionStarted
This fixes QA's real problem. QA today simulates support just to view the portal β€” stuck with the short cap and polluting the audit log. Instead, give QA accounts a real membership on a dedicated QA test merchant β†’ they enter via "Your merchants" on a normal session. Support security untouched.
DB reality (verified): the system uses a single Merchant DB today (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.

The bug: 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.

must implement correctly Re-validate authorization on every rotation. Because refresh must now reconstruct the stored context (Β§2 G1 fix), it MUST also re-check the admin is still active and still has access to that merchant on every rotation β€” else a de-privileged admin keeps cross-tenant access. The single most important control. RefreshTokenEndpoint step 6 Β· SessionIssuer.RotateAsync Β· RefreshToken.Context/.MerchantPublicId

9Timeouts β€” OWASP-aligned, configurable

TimeoutTodayProposedOWASP
Access JWT15 min15 min βœ“short β€” standard
Idle30 min30 min (config)15–30 min
Absolute (prod, normal)12h8h4–8h workday
Absolute (QA, normal)12h8–12hQA uses normal membership
Support sessionβ€”1helevated β†’ 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?

AttackCopy the session?Why / control
Read refresh token via XSS/JSNoHttpOnly β€” JS can't read it; XSS can't exfiltrate it.
Abuse in-memory JWT via XSSWhile script runsInherent to SPAs. Mitigated by CSP, SAST, same-origin isolation. No portable copy (no refresh cookie).
Sniff the networkNoSecure + HTTPS everywhere.
Copy cookies (malware/device theft)Possible, but detectedRotation + reuse detection revoke the whole chain on replay; absolute cap bounds it.
Cross-site forgery (CSRF)NoSameSite=Strict + double-submit nonce (kept even same-origin).
Read the CSRF cookieUseless aloneJust a nonce; worthless without the HttpOnly refresh.
Honest gap (future hardening): no device/IP binding β€” a copied cookie from another IP works until reuse-detection or the cap catches it. OWASP notes the usability cost; documented as optional future work.
Net effect of this work: theft risk goes down β€” same-origin isolation, shorter prod cap, __Host- option, memory-only broadcast.

12Security invariants & guardrails

Must stay true (don't regress)

βœ“
Refresh token HttpOnly+Secure+SameSite=Strict+Path=/api/auth+__Host-.
βœ“
JWT in memory only; never localStorage/persistent cookie.
βœ“
mid/pid only from the signed JWT.
βœ“
Rotation + reuse detection + per-session CSRF nonce.
βœ“
MerchantId first filter; RLS per connection.

New guardrails (implement correctly)

β–²
Refresh re-validates power-user authz every rotation, scoped to stored mid (Β§8).
β–²
QA long timeout bounded by a hard server-side ceiling; can't reach prod.
β–²
Keep SameSite=Strict + CSRF even under same-origin.
β–²
Cross-tab JWT broadcast stays same-origin + in-memory.

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.

P0
Design lock β€” ADR (reviewed by JJ) + STRIDE + cross-tenant test harness + test matrix.
Design
P1
Session-context foundation (load-bearing) β€” refresh reconstructs context + re-validates authz; ActivateMerchant rotates; handoff full session + 1h cap; timeouts to config.
Backend
P2
Same-origin (BFF) β€” reverse proxy + Vite proxy; __Host- cookie. Fixes #553/#554.
Infra
P3
Endpoint hardening β€” throttle + CSRF; HTTPS/HSTS; ClockSkew/issuer/audience/alg; AI PublicId fix.
Backend
P4
Frontend consistency β€” unify 401/logout; multi-tab leader-elected refresh (Web Locks).
Frontend
P5
Support UX β€” unified ReasonModal in @qiiub/ui; two-section picker; server-side search+pagination; QA membership.
Full-stack
P6
Sessions parity + docs β€” port sessions page to portal; finalize public + internal docs; fix glossary drift.
Frontend

Sequencing: P0 β†’ P1 β†’ P2 β†’ (P3+P4) β†’ P5 β†’ P6. P1 is the gate β€” nothing after it is correct until context-on-refresh is fixed.

Bug / gate Correct behavior Already exists Must implement correctly

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)

Support cap1h (re-enter with a fresh reason if longer).
QA capNormal 8–12h β€” QA uses real membership, not support mode.
PickerServer-side search + pagination, together.
PR orderP0 β†’ P1 β†’ P2 β†’ (P3+P4) β†’ P5 β†’ P6.
ADR reviewJJ (deputy); Mike on doubt.
Breaking OKPre-production β€” no backward-compat shims.

16References & companion artifacts

Standards

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.
The full record spans four files: 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

TermDefinition
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 proxyAPI served under the frontend host (/api) so cookies stay host-only and CSRF works.
Bootstrap / rehydrateOn 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 / SameSiteRandom per-session value echoed in a header (double-submit) + SameSite=Strict β€” blocks cross-site forgery.
Handoff / scope-accessADR-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 tokenOpaque single-use token in an HttpOnly cookie; exchanged at /auth/refresh for a fresh JWT.
Rotation / reuse detectionEach refresh revokes the old token; replaying a rotated token (past 5s grace) revokes the whole chain.
Session / SessionIdOne continuous sign-in β€” a chain of rotating refresh tokens. Created only by a real login, not a new tab.
Sliding vs absoluteSliding (30m) extends on use; absolute is the hard ceiling regardless of activity.
STRIDEMicrosoft'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.