Skip to content

Refresh Token Rotation: rotated refresh token not persisted in refreshStaleToken() #2879

@mattlacey

Description

@mattlacey

Description

When Salesforce Refresh Token Rotation is enabled on a Connected App, each time a refresh token is used it is replaced with a new one and the old token is immediately invalidated. The Android Mobile SDK does not persist the new refresh token after a successful token refresh, causing all subsequent refresh attempts to fail with an invalid refresh token error, which triggers automatic logout.

Root Cause

In ClientManager.java, the AccMgrAuthTokenProvider.refreshStaleToken() method (v13.2.0) calls UserAccountManager.getInstance().updateAccount(account, updatedUserAccount) after a successful OAuth2.refreshAuthToken() call. However:

  1. updateAccount() only calls accountManager.setUserData() for each key in the bundle built by buildAuthBundle()
  2. buildAuthBundle() does not include the refresh token — it is stored separately as the account password via accountManager.setPassword()
  3. Therefore, when TokenEndpointResponse contains a new refresh_token (as it does with Refresh Token Rotation), the new refresh token is never persisted

The next time the SDK attempts to refresh the access token, it reads the old (now-invalidated) refresh token from accountManager.getPassword(), which Salesforce rejects. Since revokedTokenShouldLogout defaults to true, this triggers an automatic logout.

Steps to Reproduce

  1. Enable Refresh Token Rotation on the Connected App in Salesforce Setup
  2. Log in to the Android app
  3. Wait for the access token to expire (or trigger a reauthentication)
  4. The initial token refresh after login succeeds, rotating the refresh token
  5. The next token refresh fails because the old refresh token is still stored in AccountManager.getPassword()
  6. The SDK logs the user out automatically

Expected Behavior

After a successful token refresh that returns a new refresh_token, the new refresh token should be persisted to AccountManager via setPassword().

Suggested Fix

In ClientManager.AccMgrAuthTokenProvider.refreshStaleToken(), after the updateAccount() call, persist the new refresh token:

UserAccountManager.getInstance().updateAccount(account, updatedUserAccount);

// Persist the rotated refresh token
if (tr.refreshToken != null && !tr.refreshToken.isEmpty()) {
    final String encryptionKey = SalesforceSDKManager.getEncryptionKey();
    final AccountManager mgr = AccountManager.get(
            SalesforceSDKManager.getInstance().getAppContext());
    mgr.setPassword(account,
            SalesforceSDKManager.encrypt(tr.refreshToken, encryptionKey));
    refreshToken = tr.refreshToken;
}

Note: the refreshToken field on AccMgrAuthTokenProvider also needs to be changed from final to non-final to allow the in-memory update.

Alternatively, updateAccount() or buildAuthBundle() could be updated to include the refresh token, but setPassword() is the mechanism used by createAccount() so it would be consistent to use the same approach.

Affected Versions

  • Confirmed on 13.2.0 (latest release as of May 2026)
  • Likely affects all versions that support Refresh Token Rotation

Environment

  • Android SDK 13.2.0 (Maven artifact com.salesforce.mobilesdk:SalesforceSDK:13.2.0)
  • OAuth User-Agent flow (hybrid_token grant type)
  • Salesforce Refresh Token Rotation enabled on Connected App

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions