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
- Launch the example app on the
ClerkSignInExample page (signed out).
- Tap the Google OAuth button; complete the flow in the external browser.
- The deep link (
clerk://…/oauth?created_session_id=…&rotating_token_nonce=…)
returns to the app; the signed-in UI renders.
- 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:
- No serialisation in
Api._fetch — concurrent requests can present a token that another response has just rotated away.
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.
TokenCache.updateFrom accepts credentials for a different client id from any response — a single stale-token response permanently re-identifies the app.
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.
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.
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_nonceexchange 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)reproduced with the
sign_in_with_googlepatrol test and manuallyClerkAuthConfigdefaults (clientRefreshPeriod: 9.7s,sessionTokenPolling: true)Steps to reproduce
ClerkSignInExamplepage (signed out).clerk://…/oauth?created_session_id=…&rotating_token_nonce=…)returns to the app; the signed-in UI renders.
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:
Key facts:
Both requests presented the same (pre-rotation) token generation.
The stale-token
GET /clientwas 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 theauthorizationheader.Whichever response arrives last wins
TokenCache. When the refresh response lands last, the SDK adopts the minted client: theupdatedAttimestamp guard inClerkAuthState.set clientdoes 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:
Api._fetch— concurrent requests can present a token that another response has just rotated away.ClerkAuthState._processDeepLinkdoes not hold the update lock — the webview SSO path wrapsparseDeepLinkinsafelyCall(which locks, engaging the existing “Guard 1” inrefreshClient), but the deep-link stream path callsparseDeepLinkbare, so timer refreshes are not suppressed during the nonce exchange.TokenCache.updateFromaccepts credentials for a different client id from any response — a single stale-token response permanently re-identifies the app.Auth.refreshClientadopts whatever comes back, includingClient.emptyon any non-200 (Api._fetchClientmaps all failures toClient.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.Api._processResponseupdates the token cache without checking the response status, so even error responses can overwrite valid credentials.Expected behaviour
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 /clientwith 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.