Migration Guide from SDK v3 to v4

June 19, 2026 · View on GitHub

Skill for Coding Agents

If you use coding agents such as Claude Code or Cursor, we highly recommend adding the Auth0.Android migration skill to your repository: npx skills add auth0/agent-skills --skill auth0-android-major-migration

Note: This guide is actively maintained during the v4 development phase. As new changes are merged, this document will be updated to reflect the latest breaking changes and migration steps.

v4 of the Auth0 Android SDK includes significant build toolchain updates, updated default values for better out-of-the-box behavior, and behavior changes to simplify credential management. This guide documents the changes required when migrating from v3 to v4.


Table of Contents


Requirements Changes

Minimum SDK Version

v4 requires API level 26 (Android 8.0 Oreo) or later (previously API 21 / Android 5.0 Lollipop).

Update your build.gradle if your minSdk is below 26:

android {
    defaultConfig {
        minSdk 26
    }
}

Impact: Apps targeting devices running Android 7.1 (API 25) or lower will need to increase their minimum SDK version, or continue using v3.

Java Version

v4 requires Java 17 or later (previously Java 8+).

Update your build.gradle to target Java 17:

android {
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_17
        targetCompatibility JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = '17'
    }
}

Gradle and Android Gradle Plugin

v4 requires:

  • Gradle: 8.11.1 or later
  • Android Gradle Plugin (AGP): 8.10.1 or later

Update your gradle/wrapper/gradle-wrapper.properties:

distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

Update your root build.gradle:

buildscript {
    dependencies {
        classpath 'com.android.tools.build:gradle:8.10.1'
    }
}

Kotlin Version

v4 uses Kotlin 2.0.21. If you're using Kotlin in your project, you may need to update your Kotlin version to ensure compatibility.

buildscript {
    ext.kotlin_version = "2.0.21"
}

Breaking Changes

Classes Removed

  • The com.auth0.android.provider.PasskeyAuthProvider class has been removed. Use the APIs from the AuthenticationAPIClient class for passkey operations:

  • The Management API support has been removed. This includes the UsersAPIClient class, ManagementException, and ManagementCallback.

    Note: This only impacts you if your app used the Management API client (UsersAPIClient).

    Impact: Any code that references UsersAPIClient, ManagementException, or ManagementCallback will no longer compile.

    Migration: Instead of calling the Management API directly from your mobile app, expose dedicated endpoints in your own backend that perform the required operations, and call those from the app using the access token you already have.

    For example, if you were reading or updating user metadata:

    1. Create a backend endpoint (e.g. PATCH /me/metadata) that accepts the operation your app needs.
    2. Call that endpoint from your app, passing the user's access token as a Bearer token in the Authorization header.
    3. On your backend, obtain a machine-to-machine token via the Client Credentials flow and use it to call the Management API with the precise scopes required.

Deprecated MFA Methods Removed from AuthenticationAPIClient

The following MFA methods have been removed from AuthenticationAPIClient. They were deprecated in v3 in favor of the MfaApiClient class APIs.

  • loginWithOTP(mfaToken, otp)
  • loginWithOOB(mfaToken, oobCode, bindingCode)
  • loginWithRecoveryCode(mfaToken, recoveryCode)
  • multifactorChallenge(mfaToken, challengeType, authenticatorId)

Use AuthenticationAPIClient.mfaClient(mfaToken) to obtain a MfaApiClient instance and handle MFA flows using the new APIs. See the MFA Flexible Factors Grant section in EXAMPLES.md for usage guidance.

DPoP Configuration Moved to Builder

The useDPoP(context: Context) method has been moved from the WebAuthProvider object to the login Builder class. This change allows DPoP to be configured per-request instead of globally.

v3 (global configuration — no longer supported):

// ❌ This no longer works
WebAuthProvider
    .useDPoP(context)
    .login(account)
    .start(context, callback)

v4 (builder-based configuration — required):

// ✅ Use this instead
WebAuthProvider
    .login(account)
    .useDPoP(context)
    .start(context, callback)

This change ensures that DPoP configuration is scoped to individual login requests rather than persisting across the entire application lifecycle.

DPoPException.UNSUPPORTED_ERROR Removed

The DPoPException.UNSUPPORTED_ERROR constant has been removed. With the minimum SDK raised to API 26, the SDK no longer needs to guard against unsupported Android versions for DPoP, so this error code is no longer applicable.

Impact: If your code references DPoPException.UNSUPPORTED_ERROR (e.g., in a catch block or error-handling logic), remove that reference. DPoP is supported on all API levels that v4 targets, so this check is no longer needed.

SSOCredentials.expiresIn Renamed to expiresAt

Change: The expiresIn property in SSOCredentials has been renamed to expiresAt and its type changed from Int to Date.

In v3, expiresIn held the raw number of seconds until the session transfer token expired. In v4, the SDK now automatically converts this value into an absolute expiration Date (computed as current time + seconds) during deserialization, consistent with how Credentials.expiresAt works. The property has been renamed to expiresAt to reflect that it now represents an absolute point in time rather than a duration.

v3:

val ssoCredentials: SSOCredentials = // ...
val secondsUntilExpiry: Int = ssoCredentials.expiresIn

v4:

val ssoCredentials: SSOCredentials = // ...
val expirationDate: Date = ssoCredentials.expiresAt

Impact: If your code references ssoCredentials.expiresIn, rename it to ssoCredentials.expiresAt. The value is now an absolute Date instead of a duration in seconds.

SecureCredentialsManager Auth0 Constructors Removed

Change: The two SecureCredentialsManager constructors that accepted an Auth0 instance as their first parameter have been removed. Only the AuthenticationAPIClient-based constructors remain.

In v3, SecureCredentialsManager offered four public constructors — two that accepted an Auth0 object (which the manager used internally to create an AuthenticationAPIClient) and two that accepted a pre-built AuthenticationAPIClient directly. In v4 the Auth0-based constructors are gone, leaving two constructors that both require an AuthenticationAPIClient.

v3 (removed):

// ❌ Auth0-based constructor — no longer exists
val manager = SecureCredentialsManager(
    auth0,    // Auth0 instance
    context,
    storage
)

// ❌ Auth0-based biometric constructor — no longer exists
val manager = SecureCredentialsManager(
    auth0,    // Auth0 instance
    context,
    storage,
    fragmentActivity,
    localAuthenticationOptions
)

v4 (required):

The manager uses the supplied AuthenticationAPIClient for all token renewals and DPoP-bound refreshes, so configure that client first and then pass the same instance into SecureCredentialsManager.

// ✅ Create the AuthenticationAPIClient first, then pass it in
val apiClient = AuthenticationAPIClient(auth0)
val manager = SecureCredentialsManager(apiClient, context, storage)

// ✅ Biometric variant
val apiClient = AuthenticationAPIClient(auth0)
val manager = SecureCredentialsManager(
    apiClient,
    context,
    storage,
    fragmentActivity,
    localAuthenticationOptions
)
Using Java
// ✅ Standard
AuthenticationAPIClient apiClient = new AuthenticationAPIClient(auth0);
SecureCredentialsManager manager = new SecureCredentialsManager(apiClient, context, storage);

// ✅ Biometric variant
AuthenticationAPIClient apiClient = new AuthenticationAPIClient(auth0);
SecureCredentialsManager manager = new SecureCredentialsManager(
    apiClient, context, storage, fragmentActivity, localAuthenticationOptions);

Impact: Any code that constructs SecureCredentialsManager with an Auth0 instance as the first argument will no longer compile. Create an AuthenticationAPIClient(auth0) first and pass that instead. The same change applies to Java — there is no Java-specific overload.

Reason: The Auth0 parameter was redundant — AuthenticationAPIClient already holds a reference to the Auth0 configuration object. Removing it eliminates the duplication, makes DPoP opt-in configuration (via AuthenticationAPIClient.useDPoP(context)) a natural part of construction, and reduces the public API surface.


Default Values Changed

Credentials Manager minTTL

Change: The default minTtl value changed from 0 to 60 seconds.

This change affects the following Credentials Manager methods:

  • getCredentials(callback) / awaitCredentials()
  • getCredentials(scope, minTtl, callback) / awaitCredentials(scope, minTtl)
  • getCredentials(scope, minTtl, parameters, callback) / awaitCredentials(scope, minTtl, parameters)
  • getCredentials(scope, minTtl, parameters, forceRefresh, callback) / awaitCredentials(scope, minTtl, parameters, forceRefresh)
  • getCredentials(scope, minTtl, parameters, headers, forceRefresh, callback) / awaitCredentials(scope, minTtl, parameters, headers, forceRefresh)
  • hasValidCredentials()

Impact: Credentials will be renewed if they expire within 60 seconds, instead of only when already expired.

Migration example
// v3 - minTtl defaulted to 0, had to be set explicitly
credentialsManager.getCredentials(scope = null, minTtl = 60, callback = callback)

// v4 - minTtl defaults to 60 seconds
credentialsManager.getCredentials(callback)

// v4 - use 0 to restore v3 behavior
credentialsManager.getCredentials(scope = null, minTtl = 0, callback = callback)

Reason: A minTtl of 0 meant credentials were not renewed until expired, which could result in delivering access tokens that expire immediately after retrieval, causing subsequent API requests to fail. Setting a default value of 60 seconds ensures the access token remains valid for a reasonable period.

Behavior Changes

CredentialsManager Now Uses the Global Executor

Change: CredentialsManager no longer creates a per-instance Executor. It now uses the same process-wide single-thread executor already used by SecureCredentialsManager .

In v3, each CredentialsManager instance created its own Executors.newSingleThreadExecutor(). Two CredentialsManager instances could therefore run getCredentials concurrently, racing to exchange the same refresh token and potentially triggering duplicate invalid_grant errors on token rotation. SecureCredentialsManager was already on the global executor — this was an inconsistency between the two manager types.

In v4, getCredentials and getApiCredentials calls from any manager instance backed by the same Auth0 object are queued on one global single-thread executor. The first caller renews the token, saves the updated credentials, and returns. Subsequent callers find the already-refreshed credentials in storage and return without making a network request.

Impact: If your app creates multiple CredentialsManager instances backed by the same Auth0 object, their renewal operations are now serialized rather than concurrent. In practice this eliminates duplicate refresh-token exchanges. The only observable downside is that a slow renewal blocks other callers until it completes.

No code changes are required. This is a runtime-only behavior change.


clearCredentials() Now Clears All Storage

Change: clearCredentials() now calls Storage.removeAll() instead of removing individual credential keys.

In v3, clearCredentials() removed only specific credential keys (access token, refresh token, ID token, etc.) from the underlying Storage.

In v4, clearCredentials() calls Storage.removeAll(), which clears all values in the storage — including any API credentials stored for specific audiences.

Impact: If you need to remove only the primary credentials while preserving other stored data, consider using a separate Storage instance for API credentials.

Reason: This simplifies credential cleanup and ensures no stale data remains in storage after logout. It aligns the behavior with the Swift SDK's clear() method, which also clears all stored values.

Storage Interface: New removeAll() Method

Change: The Storage interface now includes a removeAll() method with a default empty implementation.

Impact: Existing custom Storage implementations will continue to compile and work without changes. Override removeAll() to provide the actual clearing behavior if your custom storage is used with clearCredentials().

New APIs

clearAll() — Full Credential and Key Cleanup

v4 introduces a new clearAll() method on CredentialsManager and SecureCredentialsManager that performs a complete cleanup of all stored credentials and cryptographic key pairs.

Usage:

// Clear everything on logout — credentials, DPoP keys, and encryption keys
credentialsManager.clearAll()

When to use clearAll() vs clearCredentials():

  • Use clearCredentials() when you only need to remove stored tokens (e.g., forcing a re-login) but want to preserve cryptographic keys for future sessions.
  • Use clearAll() on full logout or account removal, when you want to ensure no credentials or key material remain on the device.

Note: clearAll() catches any errors from DPoP key pair deletion internally, so it will not throw even if the DPoP key pair was never created or has already been removed.

Dependency Changes

⚠️ Gson 2.8.9 → 2.11.0 (Transitive Dependency)

v4 updates the internal Gson dependency from 2.8.9 to 2.11.0. While the SDK does not expose Gson types in its public API, Gson is included as a transitive runtime dependency. If your app also uses Gson, be aware of the following changes introduced in Gson 2.10+:

  • TypeToken with unresolved type variables is rejected at runtime. Code like object : TypeToken<List<T>>() {} (where T is a generic parameter) will throw IllegalArgumentException. Use Kotlin reified type parameters or pass concrete types instead.
  • Strict type coercion is enforced. Gson no longer silently coerces JSON objects or arrays to String. If your code relies on this behavior, you will see JsonSyntaxException.
  • Built-in ProGuard/R8 rules are included. Gson 2.11.0 ships its own keep rules, so you may be able to remove custom Gson ProGuard rules from your project.

If you need to pin Gson to an older version, you can use Gradle's resolutionStrategy:

configurations.all {
    resolutionStrategy.force 'com.google.code.gson:gson:2.8.9'
}

Alternatively, you can exclude Gson from the SDK entirely and provide your own version:

implementation('com.auth0.android:auth0:<version>') {
    exclude group: 'com.google.code.gson', module: 'gson'
}
implementation 'com.google.code.gson:gson:2.8.9' // your preferred version

Note: Pinning or excluding is not recommended long-term, as the SDK has been tested and validated against Gson 2.11.0.

DefaultClient.Builder

v4 introduces a DefaultClient.Builder for configuring the HTTP client. This replaces the constructor-based approach with a more flexible builder pattern that supports additional options such as write/call timeouts, custom interceptors, and custom loggers.

v3 (constructor-based — deprecated):

// ⚠️ Deprecated: still compiles but shows a warning
val client = DefaultClient(
    connectTimeout = 30,
    readTimeout = 30,
    enableLogging = true
)

v4 (builder pattern — recommended):

val client = DefaultClient.Builder()
    .connectTimeout(30)
    .readTimeout(30)
    .writeTimeout(30)
    .callTimeout(120)
    .enableLogging(true)
    .build()

The legacy constructor is deprecated but not removed — existing code will continue to compile and run. Your IDE will show a deprecation warning with a suggested ReplaceWith quick-fix to migrate to the Builder.

New APIs

Handling Configuration Changes During Authentication

v4 fixes a memory leak and lost callback issue when the Activity is destroyed during authentication (e.g. device rotation, locale change, dark mode toggle). The SDK wraps the callback in a LifecycleAwareCallback that observes the host Activity/Fragment lifecycle. When onDestroy fires, the reference to the callback is immediately nulled out so the destroyed Activity is no longer held in memory.

If the authentication result arrives while the Activity is being recreated, it is cached internally. Call WebAuthProvider.registerCallbacks() once in your onCreate() — this single call handles both recovery scenarios and manages the callback lifecycle automatically:

class LoginActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        WebAuthProvider.registerCallbacks(
            lifecycleOwner = this,
            loginCallback = object : Callback<Credentials, AuthenticationException> {
                override fun onSuccess(result: Credentials) { /* handle credentials */ }
                override fun onFailure(error: AuthenticationException) { /* handle error */ }
            },
            logoutCallback = object : Callback<Void?, AuthenticationException> {
                override fun onSuccess(result: Void?) { /* handle logout */ }
                override fun onFailure(error: AuthenticationException) { /* handle error */ }
            }
        )
    }

    fun onLoginClick() {
        WebAuthProvider.login(account)
            .withScheme("myapp")
            .start(this, callback)
    }
}

registerCallbacks() covers both scenarios in one call:

ScenarioHow it's handled
Configuration change (rotation, locale, dark mode)The result is delivered directly to the registered callback once the async token exchange completes. If no callback is registered yet, the result is cached and delivered on the next onResume
Process death (system killed the app while browser was open)AuthenticationActivity restores the OAuth state and processes the redirect. Since all static state (including callbacks) was wiped, the result is cached in pendingLoginResult. When your Activity is recreated and calls registerCallbacks(), the cached result is delivered on the next onResume

Note: Both loginCallback and logoutCallback are required — this ensures results from either flow are never lost during configuration changes or process death.

Note: If you use the suspend fun await() API from a ViewModel coroutine scope, the Activity is never captured in the callback chain, so you do not need registerCallbacks() calls. See the sample app for a ViewModel-based example.

Getting Help

If you encounter issues during migration: