Skip to content

bug: in-flight token refresh resurrects credentials after signOut (sign-out / refresh race) #253

@HarlonWang

Description

@HarlonWang

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions