Keycloak Authentication for Native Android (Kotlin)
Last updated: June 2026
Native Android apps authenticate with Keycloak using the OAuth 2.0 authorization-code flow with PKCE through Chrome Custom Tabs, typically via the AppAuth-Android library. Always configure a PUBLIC client with no client secret — the APK can be decompiled, so any secret you embed becomes a public secret the moment your app ships.
RFC 8252 (“OAuth 2.0 for Native Apps”) codified this pattern in 2017 and it remains the mandatory approach for any OAuth-based mobile authentication. This tutorial walks through the entire integration: creating the Keycloak client, adding AppAuth-Android via Gradle, launching Chrome Custom Tabs, handling the redirect, exchanging the authorization code for tokens, storing them in EncryptedSharedPreferences, refreshing silently, calling a protected API, and logging out cleanly.
For a broader look at why PKCE matters on mobile, see our OAuth 2.0 best practices for mobile apps guide.
Why PKCE and a Public Client?
Mobile apps cannot keep secrets. When you distribute an APK, every byte of the compiled binary is accessible to anyone who downloads it — reverse-engineering tools can extract a hardcoded client_secret in seconds. RFC 8252 addresses this by defining two requirements for native apps:
- Public client: no
client_secreton the client side at all. - PKCE (Proof Key for Code Exchange): a per-request cryptographic challenge that replaces the secret. The app generates a random
code_verifier, hashes it to produce acode_challenge, sends the challenge in the authorization request, and proves ownership of the verifier when exchanging the code for tokens.
PKCE prevents authorization code interception attacks. Even if a malicious app registers the same custom URI scheme and intercepts the redirect, it cannot exchange the code because it does not know the code_verifier that was generated in memory by the legitimate app.
Chrome Custom Tabs (the system browser in a lightweight in-app frame) add another layer: the user authenticates in an isolated browser context that your app code cannot inspect. The alternative — a WebView — lets app code intercept credentials and is explicitly forbidden by RFC 8252 and Google Play policy.
For the cross-platform comparison, our Flutter mobile authentication guide and React Native + Expo guide cover the same PKCE pattern on those stacks.
Step 1: Configure the Keycloak Client
Log into your Keycloak Admin Console at https://your-keycloak-host/admin (Keycloak 26.x uses /realms/<realm> paths). If you do not have a running instance, managed Keycloak hosting at Skycloak removes the infrastructure overhead.
Create the realm and client
- Create a realm named
android-app(or use an existing one). - Go to Clients > Create client.
- Set Client type to
OpenID Connectand Client ID toandroid-native. - On the Capability config screen:
- Client authentication: Off (public client — no secret).
- Standard flow: Enabled.
- Direct access grants: Disabled (resource owner password flow is deprecated for mobile).
- On the Login settings screen, add your redirect URIs:
io.skycloak.androidapp:/oauth2redirect
https://your-package-name.app.link/oauth2redirect
The first is a custom URI scheme (simple, works on all Android versions). The second is an App Link (HTTPS-based, verified via Digital Asset Links — covered briefly later). Use the custom scheme for development; prefer App Links in production.
- Set Valid post-logout redirect URIs to the same values.
- Under the Advanced tab, confirm Proof Key for Code Exchange Code Challenge Method is set to
S256.
Create a test user
Go to Users > Add user, set a username and email, then under Credentials set a temporary password. Under Role mappings, assign any realm roles your app needs.
Step 2: Add AppAuth-Android via Gradle
AppAuth-Android is the official OpenID Foundation library for OAuth 2.0 and OpenID Connect on Android. Add it to your module-level build.gradle.kts:
dependencies {
implementation("net.openid:appauth:0.11.1")
// EncryptedSharedPreferences for secure token storage
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Coroutines for async token exchange
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
AppAuth-Android requires the net.openid.appauth.RedirectUriReceiverActivity to handle the OAuth redirect. Register it in AndroidManifest.xml:
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true">
<!-- Custom URI scheme -->
<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="io.skycloak.androidapp"
android:host="oauth2redirect" />
</intent-filter>
<!-- App Link (HTTPS) - optional but recommended for production -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="your-package-name.app.link"
android:path="/oauth2redirect" />
</intent-filter>
</activity>
App links verification: when you use HTTPS redirect URIs, Android verifies your domain ownership by fetching https://your-domain/.well-known/assetlinks.json. Without a valid assetlinks.json, Android falls back to the browser, breaking the redirect. The custom URI scheme requires no verification, which is why it’s easier for development.
Step 3: Discover Keycloak Endpoints and Build the Authorization Request
Keycloak exposes an OIDC discovery document at:
https://your-keycloak-host/realms/android-app/.well-known/openid-configuration
AppAuth fetches this automatically. Create a helper that discovers the configuration and builds the authorization request:
import android.content.Context
import android.net.Uri
import net.openid.appauth.*
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
object KeycloakAuthManager {
private const val KEYCLOAK_BASE = "https://your-keycloak-host"
private const val REALM = "android-app"
private const val CLIENT_ID = "android-native"
private const val REDIRECT_URI = "io.skycloak.androidapp:/oauth2redirect"
private val issuerUri = Uri.parse("$KEYCLOAK_BASE/realms/$REALM")
private val redirectUri = Uri.parse(REDIRECT_URI)
// Fetch OIDC discovery document (suspending)
suspend fun discoverServiceConfig(): AuthorizationServiceConfiguration =
suspendCoroutine { cont ->
AuthorizationServiceConfiguration.fetchFromIssuer(issuerUri) { config, ex ->
if (config != null) cont.resume(config)
else cont.resumeWithException(ex ?: Exception("Discovery failed"))
}
}
// Build the authorization request with PKCE
fun buildAuthRequest(
serviceConfig: AuthorizationServiceConfiguration
): AuthorizationRequest {
return AuthorizationRequest.Builder(
serviceConfig,
CLIENT_ID,
ResponseTypeValues.CODE,
redirectUri
)
.setScope("openid email profile offline_access")
.build()
// AppAuth generates and stores the code_verifier/code_challenge automatically
// when you call AuthorizationService.getAuthorizationRequestIntent()
}
}
offline_access in the scope requests a refresh token from Keycloak. Without it, you can’t silently re-authenticate after the access token expires.
Step 4: Launch Chrome Custom Tabs
In your LoginActivity, discover the service configuration, build the request, and launch the Custom Tab. The authorization service creates the Custom Tab intent:
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import net.openid.appauth.AuthorizationService
class LoginActivity : AppCompatActivity() {
private lateinit var authService: AuthorizationService
// Register the result launcher before onCreate (required from API 33+)
private val authLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
handleAuthorizationResult(result.resultCode, result.data)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
authService = AuthorizationService(this)
findViewById<android.widget.Button>(R.id.btn_login).setOnClickListener {
startLogin()
}
}
private fun startLogin() {
lifecycleScope.launch {
try {
val serviceConfig = KeycloakAuthManager.discoverServiceConfig()
val authRequest = KeycloakAuthManager.buildAuthRequest(serviceConfig)
val authIntent = authService.getAuthorizationRequestIntent(authRequest)
authLauncher.launch(authIntent)
} catch (e: Exception) {
// Show error to user
}
}
}
private fun handleAuthorizationResult(resultCode: Int, data: Intent?) {
if (resultCode == Activity.RESULT_CANCELED || data == null) {
// User cancelled or error — stay on login screen
return
}
val response = net.openid.appauth.AuthorizationResponse.fromIntent(data)
val ex = net.openid.appauth.AuthorizationException.fromIntent(data)
if (response != null) {
exchangeCodeForTokens(response)
} else {
// Log ex.error and ex.errorDescription for debugging
}
}
override fun onDestroy() {
super.onDestroy()
authService.dispose()
}
}
AuthorizationService.getAuthorizationRequestIntent() is what triggers PKCE: AppAuth generates the code_verifier, stores it in the AuthorizationRequest, hashes it to code_challenge with SHA-256, and appends code_challenge and code_challenge_method=S256 to the authorization URL automatically.
Step 5: Exchange the Authorization Code for Tokens
When Chrome Custom Tabs redirect back to your app with ?code=..., AppAuth’s RedirectUriReceiverActivity captures the intent and returns it to your launcher. Now exchange the code:
private fun exchangeCodeForTokens(authResponse: AuthorizationService.AuthorizationResponse) {
val tokenRequest = authResponse.createTokenExchangeRequest()
authService.performTokenRequest(tokenRequest) { tokenResponse, ex ->
if (tokenResponse != null) {
// Persist the AuthState (contains access + refresh + id tokens)
val authState = AuthState(authResponse, tokenResponse, null)
TokenStorage.save(this, authState)
navigateToMain()
} else {
// Handle token exchange error
}
}
}
authResponse.createTokenExchangeRequest() includes the code_verifier that AppAuth stored in the original AuthorizationRequest. Keycloak verifies this against the code_challenge it received earlier — completing the PKCE handshake. No secret, no interception risk.
Step 6: Store Tokens Securely with EncryptedSharedPreferences
Never store tokens in plain SharedPreferences. Plain preferences write to an XML file on the device that other apps (or a rooted OS) can read. Use EncryptedSharedPreferences, which wraps Android Keystore-backed AES-256-GCM encryption:
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import net.openid.appauth.AuthState
object TokenStorage {
private const val FILE_NAME = "skycloak_auth_state"
private const val KEY_AUTH_STATE = "auth_state_json"
private fun getPreferences(context: Context) =
EncryptedSharedPreferences.create(
context,
FILE_NAME,
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun save(context: Context, authState: AuthState) {
getPreferences(context).edit()
.putString(KEY_AUTH_STATE, authState.jsonSerializeString())
.apply()
}
fun load(context: Context): AuthState? {
val json = getPreferences(context)
.getString(KEY_AUTH_STATE, null) ?: return null
return AuthState.jsonDeserialize(json)
}
fun clear(context: Context) {
getPreferences(context).edit().remove(KEY_AUTH_STATE).apply()
}
}
MasterKey stores the AES-256-GCM master key in Android Keystore, which is backed by a hardware security module on devices that support it. The data key never leaves the secure enclave in plaintext. This is the right baseline for OAuth tokens on Android — avoid EncryptedFile for tokens (unnecessary overhead) and avoid the AccountManager (complex, overkill for a single-tenant app).
For a broader look at token storage patterns across platforms, see our JWT lifecycle and refresh strategies guide.
Step 7: Refresh Tokens Silently with AuthState
AppAuth’s AuthState tracks whether the current access token is still valid and performs the refresh automatically when you call performActionWithFreshTokens:
fun callApi(context: Context, onToken: (String) -> Unit, onError: (Exception) -> Unit) {
val authState = TokenStorage.load(context) ?: run {
onError(Exception("Not authenticated")); return
}
val authService = AuthorizationService(context)
authState.performActionWithFreshTokens(authService) { accessToken, _, ex ->
authService.dispose()
if (accessToken != null) {
// Save updated AuthState (the refresh updates expiry/token values)
TokenStorage.save(context, authState)
onToken(accessToken)
} else {
// Refresh failed (token revoked, network error) — force re-login
TokenStorage.clear(context)
onError(ex ?: Exception("Token refresh failed"))
}
}
}
performActionWithFreshTokens checks the access token expiry. If it’s still valid, it passes the existing token immediately. If it’s expired, AppAuth sends a grant_type=refresh_token request to Keycloak’s token endpoint and updates the AuthState with fresh values. Save the updated AuthState after every refresh — the refresh token itself rotates in Keycloak by default (configurable under Realm settings > Tokens).
Step 8: Call a Protected API
Once you have a fresh access token, attach it as a Bearer token on every API request. Using OkHttp (the most common Android HTTP client):
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.*
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
suspend fun fetchProtectedResource(context: Context, url: String): String {
val client = OkHttpClient()
return suspendCancellableCoroutine { cont ->
callApi(
context,
onToken = { accessToken ->
val request = Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer $accessToken")
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
if (response.isSuccessful) {
cont.resume(response.body?.string() ?: "")
} else {
cont.resumeWithException(
Exception("HTTP ${response.code}")
)
}
}
})
},
onError = { cont.resumeWithException(it) }
)
}
}
Your API validates the Bearer token by checking Keycloak’s JWKS endpoint (/realms/android-app/protocol/openid-connect/certs). The access token is a signed JWT; the API verifies the signature, iss, aud, and exp claims without a round-trip to Keycloak. See our OAuth 2.0 visual guide for a diagram of this token validation flow.
Step 9: Logout
Logout has two parts: clearing the local token state and ending the Keycloak session via the end-session endpoint. If you skip the Keycloak-side logout, the user’s Keycloak session stays alive and they can reuse it in other apps or browsers.
fun logout(context: Context) {
val authState = TokenStorage.load(context) ?: return
val authService = AuthorizationService(context)
val serviceConfig = authState.authorizationServiceConfiguration
val idToken = authState.idToken
// Clear local state immediately
TokenStorage.clear(context)
if (serviceConfig != null && idToken != null) {
val endSessionRequest = EndSessionRequest.Builder(serviceConfig)
.setIdTokenHint(idToken)
.setPostLogoutRedirectUri(
Uri.parse("io.skycloak.androidapp:/oauth2redirect")
)
.build()
val endSessionIntent = authService.getEndSessionRequestIntent(endSessionRequest)
// Launch via startActivity or your result launcher
context.startActivity(endSessionIntent)
}
authService.dispose()
}
The id_token_hint tells Keycloak which session to terminate. The post_logout_redirect_uri brings the user back to your app after the Keycloak logout page closes. Register this URI in your Keycloak client’s Valid post-logout redirect URIs field.
Frequently asked questions
Can I use a confidential client with a client secret on Android?
No. Android APKs can be decompiled with freely available tools, so any secret embedded in the binary becomes publicly readable. RFC 8252 explicitly prohibits using confidential clients for native apps. Always configure a public client (Client authentication: Off in Keycloak) and rely on PKCE for security. If you need stronger client attestation, explore Android’s Play Integrity API as an additional layer — but PKCE remains mandatory.
What is the difference between a custom URI scheme and an App Link?
A custom URI scheme (e.g., io.skycloak.androidapp:/oauth2redirect) requires no domain verification and works immediately, but any installed app can register the same scheme, enabling redirect hijacking. An App Link (e.g., https://yourdomain.com/oauth2redirect) uses HTTPS and requires you to publish a assetlinks.json file that ties your domain to your app’s signing certificate. Android verifies this at install time. App Links are significantly harder to hijack and are the preferred approach for production. Our mobile OAuth best practices guide covers this in more depth.
Why does my token refresh fail after the app is backgrounded for a long time?
Keycloak refresh tokens expire. The default SSO Session Idle is 30 minutes and SSO Session Max is 10 hours (configurable under Realm settings > Tokens). If the app is backgrounded past the idle timeout without a refresh, the refresh token is invalid and the user must re-authenticate. Handle this by catching the refresh failure in performActionWithFreshTokens, clearing the stored AuthState, and sending the user back to the login screen. Consider requesting offline_access scope for longer-lived sessions — Keycloak issues an offline token that survives beyond the regular SSO session.
Is WebView acceptable if I control both the app and the server?
No. Google Play policy prohibits using a WebView for OAuth flows, and RFC 8252 forbids it regardless of whether you control the server. The WebView approach lets your app intercept the user’s credentials before they’re submitted, breaks password managers and passkeys, and leaks the session cookie to your app’s JavaScript context. Chrome Custom Tabs isolate the login page in the system browser’s security boundary. There are no valid exceptions to this rule.
How do I handle multiple accounts or tenant switching?
Store a separate AuthState per account using a keyed map in EncryptedSharedPreferences (key by user identifier or tenant ID). When switching accounts, load the matching AuthState and call performActionWithFreshTokens as normal. For Keycloak multi-tenancy, each tenant is typically a separate realm — update the issuerUri in KeycloakAuthManager to point to the correct realm before discovery. See our React Native Expo authentication guide for a multi-account storage pattern you can adapt to Kotlin.
What’s next
Native Android authentication with Keycloak comes down to four non-negotiable rules: public client, PKCE with S256, Chrome Custom Tabs, and EncryptedSharedPreferences. Everything else in this guide — refresh handling, API calls, logout — follows naturally from those foundations.
If you don’t want to manage Keycloak infrastructure yourself, Skycloak’s managed Keycloak hosting gives you a production-ready instance with automatic upgrades, SLA-backed uptime, and built-in multi-realm support — so you can focus on the Android integration rather than the server.
For the broader identity architecture around your mobile app, the OAuth 2.0 for Developers visual guide covers the full authorization code flow with diagrams, and the JWT token lifecycle guide explains exactly when and how to refresh, revoke, and validate tokens on the backend.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.