Skip to content

Two tabs refreshing at once sign the user out of every tab — a lost rotation race clears all session cookies #700

Description

@alex-dembele

Problem

A user with OpenRisk open in two tabs is signed out of every tab when both tabs renew their session at the same moment. The 15-minute access token expires in both tabs at once, so this happens whenever both tabs make a request within the same ~20 ms (a reload of both, two dashboards polling, a user switching back to a window with several tabs open). The refresh cookie is still valid for 30 days; nothing about the session is compromised. The user is simply dropped on the login page with "Session revoked. Please sign in again."

This survives the #691 fix (PR #692): #692 shares one refresh per tab, not across tabs.

Evidence (local stack, master abb4e02 + #697/#698/#699, 2026-09-16)

  • API level: two POST /api/v1/auth/refresh sent at once with the same refresh cookie → one 200, one 401 {"code":"REFRESH_REUSE_DETECTED","error":"Session revoked. Please sign in again."}.
  • Browser, one context, two tabs, both calling /auth/refresh at the same instant (Playwright), 3 runs out of 3:
    • before: cookies or_access, or_csrf, or_refresh;
    • results: one 200, one 401 REFRESH_REUSE_DETECTED;
    • after: no cookies at all; GET /auth/me → 401; a further /auth/refresh → 401.
  • Cause, backend: backend/internal/handler/auth/handler.go:427-438 treats a lost concurrent-rotation race exactly like a stolen-token replay: it calls middleware.ClearSessionCookies(c) and answers REFRESH_REUSE_DETECTED. In a browser the cookie jar is shared by every tab, so the losing response deletes the cookies the winning response just set.
  • Cause, frontend: frontend/src/lib/api.ts refreshSession() shares one in-flight refresh per tab only (refreshInFlight is module state), and on a failed refresh sets window.location.href = '/login'.
  • Inconsistency: the handler comment says the whole family "has already been revoked", yet with separate cookie jars the winner's new token keeps working (/auth/me 200, next refresh 200). The logout is caused by the cookie clearing, not by revocation.

Acceptance criteria

  1. Two tabs of the same browser refreshing at the same instant both end with a valid session: no tab is sent to /login, and the cookie jar still holds a working or_access / or_refresh pair.
  2. Replay of a genuinely old rotated refresh token (outside the concurrent-rotation window) is still detected, still revokes the family, and still returns REFRESH_REUSE_DETECTED.
  3. A losing concurrent request never clears cookies that a winning request set.
  4. Backend test: concurrent rotation with the same token → both callers end with a usable session (or the loser is told to retry), and the reuse test for an old token still passes.
  5. Browser test (Playwright): two tabs, access cookie removed, both reload at once → both stay signed in.

Design note — owner decision may be needed

CLAUDE.md escalates redesigns touching auth. Candidate fixes differ in security trade-off:

  • a short grace window in which the just-rotated token returns the same new pair;
  • the loser answers a retryable code without clearing cookies;
  • a cross-tab lock on the client (BroadcastChannel / Web Locks).

The implementer should record the choice in docs/DECISIONS.md if it changes reuse-detection semantics.

Definition of Done

  • Criteria 1–5 verified, with the Playwright output pasted
  • Reuse-detection security tests unchanged and green

Activity

  1. added this to the trust-v1 milestone on Sep 16, 2026
  2. alex-dembele commented on Oct 7, 2026

    @alex-dembele
    MemberAuthor

    backend-go — 2026-10-07

    Done: no auth code changed. D-048 / #777 had already fixed the cause on master. This adds the proof and the tests the issue asked for (PR #896):

    • backend/internal/handler/auth/refresh_concurrency_e2e_test.go: TestRefreshHandler_ConcurrentBurst_Success, _UnknownToken_NotFound, _ReplayAfterWindow_Unauthorized
    • frontend/e2e/session-tabs.spec.ts: two tabs, or_access dropped then a simultaneous reload, plus a simultaneous /auth/refresh from both tabs
    • backend/internal/handler/auth/handler.go: stale comment only
    • docs/700_CONCURRENT_REFRESH.md: spec and results

    Verified: throwaway Postgres 16 + Redis 7, master cefe453d. Each check was also run against the backend before #777 (9dd1c3ee) to show the tests fail there.

    Criteria: 1 ✅ · 2 ✅ · 3 ✅ · 4 ✅ · 5 ✅ (needs #893 merged to run on master: the sign-in screen crashes there today; the run above used that one-line fix applied locally)

    Next: merge PR #894 (#893) first, then PR #896. Once both are in, session-tabs.spec.ts runs on master as is.

    Blocked on: #893 / PR #894 for the Playwright run on master. Nothing else.

  3. added a commit that references this issue on Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions