In this article
August 24, 2026
August 24, 2026

Add sign-in to an Android app with the WorkOS Android SDK

Build a complete AuthKit flow in Kotlin, from Gradle dependency to signed-out state, with no client secret in your APK.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Signing users in on Android means working around a constraint the web does not have: your app cannot keep a secret. This tutorial walks through the flow that constraint leads to, using the WorkOS Android SDK to sign a user in with AuthKit, hold the resulting session, refresh it, and sign out.

The SDK is written in Kotlin, coroutine-first, and covers AuthKit plus the rest of the WorkOS API with generated, strongly typed resource methods. See the release notes for what it includes.

This guide assumes you are comfortable with Gradle, coroutines, and Compose, and that WorkOS is new to you.

What you will build

A single-activity app that:

  1. Opens AuthKit in a Chrome Custom Tab.
  2. Receives the redirect back into the app through a custom scheme.
  3. Exchanges the authorization code for tokens using PKCE.
  4. Stores the refresh token in Keystore-backed storage and keeps the access token in memory.
  5. Refreshes the access token when it expires.
  6. Sends the user to the AuthKit logout URL to end the session.

Before you start

You need a WorkOS account and the client ID for the environment you are targeting. Find it in the WorkOS dashboard under API keys. The client ID looks like client_01HXXXXXXXXXXXXXXXXXXXXXXX and is not a secret, which is exactly why it is the only credential a mobile app needs.

You will also decide on a redirect URI. For a native app this is a custom scheme you own rather than an https URL. This tutorial uses com.example.myapp://callback. Add it to the redirect URI allowlist in the dashboard under Authentication before you write any code, because AuthKit rejects a redirect it has not seen.

The one security rule that shapes everything else

Anything shipped inside an APK is extractable, including strings you thought were buried. A WorkOS API key in your app is a compromised API key. The SDK enforces this rather than trusting you to remember it: PublicClient is built with an empty API key, so it exposes only the operations that are safe without a secret, and any attempt to reach the wider service surface fails loudly against the API instead of quietly appearing to work in development.

Everything below therefore runs through PublicClient and PKCE. There is no step where you paste in a secret.

Step 1: Add the dependency

The SDK publishes to Maven Central. Add it to your module's build.gradle.kts:

  
dependencies {
    implementation("com.workos:workos-android:0.2.1")

    // For opening AuthKit in a Custom Tab.
    implementation("androidx.browser:browser:1.8.0")

    // For Keystore-backed storage of the refresh token.
    implementation("androidx.security:security-crypto:1.1.0-alpha06")
}
  

Check Maven Central for the current version of each androidx artifact rather than pinning these forever. Jetpack Security is still an alpha release, so if you would rather not depend on it, use the Android Keystore directly. The section on storing the session covers what the requirement actually is.

The SDK itself pulls in OkHttp, kotlinx.serialization, and kotlinx.datetime. It touches no Android APIs, so there is nothing to configure in your manifest on its behalf and no consumer ProGuard rules to worry about.

Step 2: Register the redirect URI

AuthKit needs a way back into your app. Declare an activity with an intent filter matching the scheme and host you registered in the dashboard:

  
<activity
    android:name=".CallbackActivity"
    android:exported="true"
    android:launchMode="singleTask">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="com.example.myapp"
            android:host="callback" />
    </intent-filter>
</activity>
  

singleTask matters. Without it, the redirect can spawn a second instance of the activity and you will find yourself debugging a state mismatch that has nothing to do with WorkOS.

Use your application ID as the scheme. A generic scheme like myapp:// can be claimed by any other app on the device, which turns your redirect into an interception point.

Step 3: Create the public client

PublicClient.create takes the client ID and nothing else. It is cheap to hold for the lifetime of the process:

  
package com.example.myapp

import com.workos.android.helpers.PublicClient

object WorkOS {
    const val CLIENT_ID = "client_01HXXXXXXXXXXXXXXXXXXXXXXX"
    const val REDIRECT_URI = "com.example.myapp://callback"

    val client: PublicClient by lazy { PublicClient.create(clientId = CLIENT_ID) }
}
  

In a real app, inject this instead of reaching for a singleton, and read the client ID from a build config field so staging and production environments do not share one.

Step 4: Start the sign-in flow

getAuthorizationUrlWithPkce generates a code verifier, derives the S256 challenge, generates a CSRF state value, and assembles the authorization URL. It does no network I/O, so it is a plain function rather than a suspend fun:

  
val start = WorkOS.client.getAuthorizationUrlWithPkce(redirectUri = WorkOS.REDIRECT_URI)
// start.url, start.codeVerifier, start.codeChallenge, start.state
  

Two of those four values have to survive until the redirect comes back. Android can kill your process while the browser is in the foreground, so in-memory variables are not enough:

  • codeVerifier, because the token exchange fails without it and cannot be retried.
  • state, so you can confirm the redirect you receive is the one you started.

Persist both, then launch:

  
class SignInViewModel(
    private val pendingAuth: PendingAuthStore,
) : ViewModel() {

    fun signIn(context: Context) {
        val start = WorkOS.client.getAuthorizationUrlWithPkce(
            redirectUri = WorkOS.REDIRECT_URI,
        )

        pendingAuth.save(codeVerifier = start.codeVerifier, state = start.state)

        CustomTabsIntent.Builder()
            .setShowTitle(true)
            .build()
            .launchUrl(context, Uri.parse(start.url))
    }
}
  

Use a Custom Tab rather than a WebView. A Custom Tab shares the system browser's cookie jar, so a user already signed in to your identity provider is not asked again, and passkeys and password managers work. A WebView gives you none of that and is rejected outright by some identity providers.

If you need to send the user straight to the sign-up screen, or scope the flow to one organization, the underlying extension on UserManagement accepts screenHint, organizationId, connectionId, loginHint, domainHint, and provider. Reach it through client.userManagement.getAuthorizationUrlWithPkce(...) when the PublicClient shorthand is not enough.

Step 5: Handle the redirect and exchange the code

The callback activity reads the code and state off the intent data, checks the state, and hands the code to the view model:

  
class CallbackActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val data = intent?.data
        val code = data?.getQueryParameter("code")
        val returnedState = data?.getQueryParameter("state")
        val error = data?.getQueryParameter("error")

        when {
            error != null -> showError(data.getQueryParameter("error_description") ?: error)
            code == null || returnedState == null -> showError("Incomplete redirect")
            else -> viewModel.completeSignIn(code, returnedState)
        }
    }
}
  

AuthKit can come back with error instead of code, for example when the user cancels or an admin has restricted access. Handle it, otherwise a cancelled sign-in looks identical to a bug.

Now the exchange. authenticateWithCode is a suspend fun that performs network I/O, so call it from a coroutine and keep it off the main thread:

  
fun completeSignIn(code: String, returnedState: String) {
    viewModelScope.launch {
        val pending = pendingAuth.load()

        if (pending == null || pending.state != returnedState) {
            pendingAuth.clear()
            _uiState.value = SignInState.Error("Sign-in could not be verified. Please try again.")
            return@launch
        }

        try {
            val auth = withContext(Dispatchers.IO) {
                WorkOS.client.authenticateWithCode(
                    code = code,
                    codeVerifier = pending.codeVerifier,
                )
            }

            sessionStore.save(auth)
            _uiState.value = SignInState.SignedIn(auth.user)
        } catch (e: WorkOSException) {
            _uiState.value = SignInState.Error(e.message)
        } finally {
            pendingAuth.clear()
        }
    }
}
  

Compare the state with a constant-time comparison if you want to be thorough, and clear the pending record whether the exchange succeeded or failed. A code verifier is single use, and leaving one on disk is a liability with no upside.

authenticateWithCode returns an AuthenticateResponse:

  
auth.user            // User: id, email, emailVerified, firstName, lastName, profilePictureUrl, ...
auth.accessToken     // String: short-lived JWT, send this to your backend
auth.refreshToken    // String: use this to get a new access token
auth.organizationId  // String?: set when the session is scoped to an organization
auth.impersonator    // AuthenticateResponseImpersonator?: non-null during admin impersonation
  

Timestamps on User such as createdAt and lastSignInAt are kotlinx.datetime.Instant values, not strings, so you can format and compare them without parsing anything yourself.

If your app shows an impersonation banner, auth.impersonator is where you find the acting admin's email. Skipping it means a support engineer looking at a customer's account has no visual indication that they are not the customer.

Step 6: Store the session

The two tokens have different lifetimes and want different treatment:

  • The access token is short lived. Keep it in memory. Writing it to disk widens the attack surface for something you can always mint again.
  • The refresh token is the long-lived credential. It belongs in Keystore-backed storage, so that a rooted device or an attacker with a filesystem backup does not walk away with a durable session.
  
class SessionStore(context: Context) {

    private val prefs = EncryptedSharedPreferences.create(
        context,
        "workos_session",
        MasterKey.Builder(context)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
            .build(),
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
    )

    @Volatile
    var accessToken: String? = null
        private set

    fun save(auth: AuthenticateResponse) {
        accessToken = auth.accessToken
        prefs.edit().putString("refresh_token", auth.refreshToken).apply()
    }

    fun refreshToken(): String? = prefs.getString("refresh_token", null)

    fun clear() {
        accessToken = null
        prefs.edit().clear().apply()
    }
}
  

Never put either token in SharedPreferences without encryption, in a log line, or in a crash report attachment. A token in a log aggregator is a token in the hands of everyone with read access to your logs.

Step 7: Refresh the access token

Access tokens expire in minutes. When yours does, trade the refresh token for a fresh pair.

One wrinkle in 0.2.1: PublicClient wraps authorization URL building and the code exchange, but not the refresh call. Reach it through userManagement on a client constructed with an empty API key, which is the same posture PublicClient takes internally:

  
private val userManagement = WorkOSClient(
    apiKey = "",
    clientId = WorkOS.CLIENT_ID,
).userManagement

suspend fun refresh(): AuthenticateResponse? {
    val stored = sessionStore.refreshToken() ?: return null

    return try {
        val refreshed = withContext(Dispatchers.IO) {
            userManagement.authenticateWithRefreshToken(refreshToken = stored)
        }
        sessionStore.save(refreshed)
        refreshed
    } catch (e: AuthenticationException) {
        // The refresh token is revoked or expired. Send the user back to sign-in.
        sessionStore.clear()
        null
    }
}
  

Refresh tokens rotate. The response carries a new refreshToken alongside the new accessToken, and the old one stops working, so save both or the next refresh fails. The save call above already does this.

Two further details worth building in early:

Serialize your refreshes. Two coroutines refreshing at once means one of them presents a rotated-away token and gets an authentication error, which typically surfaces as a user being logged out at random. Guard the call with a Mutex and have the loser await the winner's result.

Refresh before you need to, not after. Read the exp claim from the access token and refresh when it is inside a small window of expiry, rather than waiting for a 401 on a request the user is watching.

Step 8: Sign out

Clearing local tokens ends the session in your app but leaves the AuthKit session standing, so the next sign-in attempt sails through without a prompt. To end it properly, send the user to the logout URL.

The logout URL is built from the session ID, which lives in the sid claim of the access token:

  
fun sessionId(accessToken: String): String? {
    val payload = accessToken.split(".").getOrNull(1) ?: return null
    val decoded = Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
    return Json.parseToJsonElement(String(decoded))
        .jsonObject["sid"]
        ?.jsonPrimitive
        ?.content
}
  

This reads a claim, it does not verify a signature. That is fine here, because the token came from the SDK over TLS and the value is only being used to build a URL. Never use a decode like this to make an authorization decision.

Then build the URL and open it:

  
fun signOut(context: Context) {
    val sid = sessionStore.accessToken?.let(::sessionId)

    sessionStore.clear()

    if (sid != null) {
        val logoutUrl = userManagement.getLogoutUrl(
            sessionId = sid,
            returnTo = "com.example.myapp://signed-out",
        )
        CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(logoutUrl))
    }
}
  

getLogoutUrl is a pure URL builder, not a network call, so it needs no API key. Register the returnTo URI in the dashboard the same way you registered the redirect URI. Clear local state first so that a user who dismisses the Custom Tab is still signed out inside your app.

Handling errors

Every failure arrives as a typed subclass of WorkOSException. The transport never leaks an OkHttp exception, so you can pattern match instead of parsing strings:

Exception Status Usually means
BadRequestException 400 A malformed request, for example a redirect URI that does not match
AuthenticationException 401 An expired or revoked token, or a spent authorization code
AuthorizationException 403 The user is not permitted, for example blocked by an organization policy
NotFoundException 404 The referenced resource does not exist
UnprocessableEntityException 422 Valid syntax, rejected content
RateLimitExceededException 429 Too many requests, retried automatically
ServerException 5xx A WorkOS-side error, retried automatically
NetworkException none DNS, TCP, or timeout failure, so statusCode is 0

Each carries statusCode, message, code, requestId, and param. Log the requestId, since it is the fastest way for WorkOS support to find your exact request:

  
try {
    WorkOS.client.authenticateWithCode(code = code, codeVerifier = verifier)
} catch (e: NetworkException) {
    showRetry("Check your connection and try again.")
} catch (e: AuthenticationException) {
    startOver("Your sign-in link expired. Please try again.")
} catch (e: WorkOSException) {
    Log.w("WorkOS", "auth failed: ${e.statusCode} ${e.code} request=${e.requestId}")
    showGenericError()
}
  

RateLimitExceededException and ServerException are retried for you, three times by default with backoff. Tune it per call with RequestOptions(maxRetries = 0, timeoutSeconds = 10), or per client through Configuration. Turn retries off for anything you do not want attempted twice.

NetworkException is the one to design for on mobile. A phone loses connectivity mid-request often enough that the difference between a retry prompt and a generic error screen is a real difference in how your app feels.

Where to go next

Once sign-in works, the rest of the SDK is available on the same coroutine-first pattern:

  • Calling your backend. Send the access token as a bearer token and verify it server-side with the WorkOS SDK for your backend language. Do not have the app call WorkOS management endpoints directly, which is what the empty API key on PublicClient is there to prevent.
  • Auto-pagination. List methods have a companion that returns a Kotlin Flow and walks every page for you: client.organizations.listAutoPaging().collect { ... }. This is for server-side clients holding a real API key, not for your app.
  • Device flow. For Android TV and other input-constrained surfaces, createDevice and authenticateWithDeviceCode implement the device authorization grant.
  • Vault local crypto. client.vaultCrypto.encrypt and .decrypt for encrypting data with keys you manage.

The SDK is a pre-release, so pin an exact version and read the release notes before upgrading. The GitHub repo is the place to report anything that surprises you, and the API reference documents the full generated surface.