Describe the bug
LogtoClient.signOut() clears the local credentials synchronously, but it does not cancel or invalidate any token refresh that is already in flight. If a refresh (started by getAccessToken) is in flight when signOut() runs, the refresh response lands after sign-out and unconditionally writes the rotated idToken / refreshToken back into persistent storage. The user is signed out, yet a fresh, valid credential set is silently persisted — and because isAuthenticated is derived from the stored idToken, the next client construction (e.g. Android Activity recreation / cold start) reports the user as signed in again.
With refresh token rotation (the RFC 9700 baseline for public clients), the resurrected refresh token is the newly-rotated one, i.e. the only currently-valid token of the family — so this is a real, usable session, not a stale artifact.
Version
io.logto.sdk:android:2.0.2 (latest release). Deterministically reproduced on a real device.
Root cause
The token setters are write-through to PersistStorage:
refreshToken setter → storage?.setItem(...) (LogtoClient.kt L44)
idToken setter → storage?.setItem(...) (L54)
isAuthenticated get() = idToken != null (L71); loadFromStorage() rehydrates the fields from storage on construction (L480)
signOut() clears the fields synchronously (L173, L176) but has no handle on the outstanding refresh request.
The refresh path captures the refresh token into the request (L281) and, on response, verifyAndSaveTokenResponse (L401) unconditionally writes:
idToken = it // L421
accessTokenMap[accessTokenKey] = accessToken
refreshToken = responseRefreshToken // L425
There is no check for whether a sign-out happened while the request was in flight — no cancellation, no generation/epoch guard, and no serialization between signOut() and this write.
Reproduction
Deterministic repro (two LogtoClient instances sharing persistent storage; the refresh is stubbed to complete after signOut). Observed timeline on a real device:
t=0.00s getAccessToken() → access token expired → refresh request in flight
t=0.15s signOut() returns → isAuthenticated=false, storage empty
t=1.34s in-flight refresh responds → verifyAndSaveTokenResponse writes tokens back
→ isAuthenticated=true, storage repopulated (rotated tokens)
t=... new LogtoClient() → loadFromStorage() → isAuthenticated=true ← resurrected
The race window equals one refresh round-trip (~1.3s observed), so it is easy to hit whenever any screen requests an access token shortly before the user signs out.
Suggested fix
Add a sign-out generation guard inside the client: snapshot a monotonically increasing counter when an async token flow starts (signIn and the refresh path), increment it in signOut(), and have verifyAndSaveTokenResponse discard the response (complete with NOT_AUTHENTICATED) if the snapshot no longer matches. This is minimal, covers both the refresh and authorization-code paths at their single shared persistence point, and a monotonic counter (rather than a boolean flag) also handles the "sign-out → immediate re-sign-in" case where a pre-logout response would otherwise overwrite the new session.
Note
The same write-back-after-sign-out pattern appears to exist in the Logto JS SDK (packages/client/src/client.ts, the refresh path persists tokens unconditionally), in case you want to address it consistently across SDKs.
Describe the bug
LogtoClient.signOut()clears the local credentials synchronously, but it does not cancel or invalidate any token refresh that is already in flight. If a refresh (started bygetAccessToken) is in flight whensignOut()runs, the refresh response lands after sign-out and unconditionally writes the rotatedidToken/refreshTokenback into persistent storage. The user is signed out, yet a fresh, valid credential set is silently persisted — and becauseisAuthenticatedis derived from the storedidToken, the next client construction (e.g. AndroidActivityrecreation / cold start) reports the user as signed in again.With refresh token rotation (the RFC 9700 baseline for public clients), the resurrected refresh token is the newly-rotated one, i.e. the only currently-valid token of the family — so this is a real, usable session, not a stale artifact.
Version
io.logto.sdk:android:2.0.2(latest release). Deterministically reproduced on a real device.Root cause
The token setters are write-through to
PersistStorage:refreshTokensetter →storage?.setItem(...)(LogtoClient.ktL44)idTokensetter →storage?.setItem(...)(L54)isAuthenticated get() = idToken != null(L71);loadFromStorage()rehydrates the fields from storage on construction (L480)signOut()clears the fields synchronously (L173, L176) but has no handle on the outstanding refresh request.The refresh path captures the refresh token into the request (L281) and, on response,
verifyAndSaveTokenResponse(L401) unconditionally writes:There is no check for whether a sign-out happened while the request was in flight — no cancellation, no generation/epoch guard, and no serialization between
signOut()and this write.Reproduction
Deterministic repro (two
LogtoClientinstances sharing persistent storage; the refresh is stubbed to complete aftersignOut). Observed timeline on a real device:The race window equals one refresh round-trip (~1.3s observed), so it is easy to hit whenever any screen requests an access token shortly before the user signs out.
Suggested fix
Add a sign-out generation guard inside the client: snapshot a monotonically increasing counter when an async token flow starts (
signInand the refresh path), increment it insignOut(), and haveverifyAndSaveTokenResponsediscard the response (complete withNOT_AUTHENTICATED) if the snapshot no longer matches. This is minimal, covers both the refresh and authorization-code paths at their single shared persistence point, and a monotonic counter (rather than a boolean flag) also handles the "sign-out → immediate re-sign-in" case where a pre-logout response would otherwise overwrite the new session.Note
The same write-back-after-sign-out pattern appears to exist in the Logto JS SDK (
packages/client/src/client.ts, the refresh path persists tokens unconditionally), in case you want to address it consistently across SDKs.