Skip to content

Silent sign-out after external-browser SSO: client refresh races the rotating-token nonce exchange #425

Description

@MarkOSullivan94

Completing an OAuth sign-in that round-trips through an external browser (e.g. Sign in with Google via a Chrome custom tab) can silently sign the user out seconds later.

Theperiodic client refresh (clientRefreshPeriod, default 9.7 s) races therotating_token_nonce exchange performed by the deep-link handler: both requests present the same client token, the OAuth completion rotates that token server-side, and the refresh response — now carrying a freshly minted, session-less client — is adopted by the SDK, replacing the signed-in client. No error is surfaced.

The failure is timing-dependent (whichever response lands last wins the token cache), so it reproduces intermittently. When it hits, the poisoned client identity is persisted, so the app relaunches as the wrong client.

Environment

  • clerk_flutter / clerk_auth @ main (observed at cd4b95b)
  • Development Clerk instance
  • Android emulator, Sign in with Google via external browser (Chrome custom tab),
    reproduced with the sign_in_with_google patrol test and manually
  • ClerkAuthConfig defaults (clientRefreshPeriod: 9.7s, sessionTokenPolling: true)

Steps to reproduce

  1. Launch the example app on the ClerkSignInExample page (signed out).
  2. Tap the Google OAuth button; complete the flow in the external browser.
  3. The deep link (clerk://…/oauth?created_session_id=…&rotating_token_nonce=…)
    returns to the app; the signed-in UI renders.
  4. Within ~10 seconds (the first refresh-timer tick after the exchange), the UI
    reverts to signed-out. Reproduction is intermittent — see “Race mechanics”.

Observed behaviour (instrumented logs)

Request/response logging with decoded client-token claims shows the race directly:

-> GET /client/sign_ins/sia_3G088Ms…  presenting {"id":"client_3G07ER…","rotating_token":"uwnbto…"}   (nonce exchange)
-> GET /client                        presenting {"id":"client_3G07ER…","rotating_token":"uwnbto…"}   (timer refresh, same token)
<- 200 /client            authorization: {"id":"client_3G089fY…","rotating_token":"7z7upi…"}
   → token cache now belongs to client_3G089fY… (0 sessions)   ← SILENT SIGN-OUT
<- 200 /client/sign_ins/… authorization: {"id":"client_3G07ER…","rotating_token":"72t5pn…"}
   → token cache back to client_3G07ER… (1 session)            ← this run recovered by luck

Key facts:

  • Both requests presented the same (pre-rotation) token generation.

  • The stale-token GET /client was answered by the dev-instance FAPI with HTTP 200 and a brand-new client (different id, zero sessions, updatedAt ~100 ms after the session was created), plus that client's token in the authorization header.

  • Whichever response arrives last wins TokenCache. When the refresh response lands last, the SDK adopts the minted client: the updatedAt timestamp guard in ClerkAuthState.set client does not block it (the minted client is newer), and the user is signed out with no error. The wrong identity is then persisted across launches.

Root cause

The client token is a mutable, rotating credential, but the SDK allows multiple in-flight requests to use and update it concurrently:

  1. No serialisation in Api._fetch — concurrent requests can present a token that another response has just rotated away.
  2. ClerkAuthState._processDeepLink does not hold the update lock — the webview SSO path wraps parseDeepLink in safelyCall (which locks, engaging the existing “Guard 1” in refreshClient), but the deep-link stream path calls parseDeepLink bare, so timer refreshes are not suppressed during the nonce exchange.
  3. TokenCache.updateFrom accepts credentials for a different client id from any response — a single stale-token response permanently re-identifies the app.
  4. Auth.refreshClient adopts whatever comes back, including Client.empty on any non-200 (Api._fetchClient maps all failures to Client.empty). On production instances — where a stale token yields a 401 rather than a minted client — this means any transient failure of the background refresh also signs the user out.
  5. Api._processResponse updates the token cache without checking the response status, so even error responses can overwrite valid credentials.

Expected behaviour

  • A background client refresh must never change which client the app is, and must never sign the user out on a transient failure or a mid-flight token rotation.
  • Only explicit operations (signOut, resetClient) should clear a signed-in client. SSO path.

Server-side note (possibly a separate FAPI issue)

A development-instance FAPI answering a stale-rotating-token GET /client with 200 + a freshly minted client rather than a 401 makes this failure mode silent and self-perpetuating from the SDK's perspective. It may be worth raising with the FAPI team independently.

Metadata

Metadata

Type

Fields

No fields configured for Bug.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions