androidengineers.Book a session

Security Architecture

OAuth2/OIDC on Mobile

article20 minHard

OAuth 2.0 with PKCE (Proof Key for Code Exchange) is the current best practice for mobile app authentication. It avoids embedding client secrets in the app while still providing secure authorization code exchange.

Why PKCE (Not Client Secret)

Traditional OAuth 2.0 uses a client_secret to authenticate the app when exchanging the authorization code for tokens. But mobile apps can't keep secrets — the APK is inspectable. PKCE replaces the client secret with a cryptographic challenge/verifier pair generated at runtime.

1. App generates: code_verifier (random 43–128 char string)
2. App computes:  code_challenge = BASE64URL(SHA256(code_verifier))
3. Auth request:  /authorize?code_challenge=...&code_challenge_method=S256
4. Token exchange: /token with code_verifier (not code_challenge)
5. Server verifies: SHA256(code_verifier) == code_challenge stored in step 3

AppAuth for Android

AppAuth is the OpenID Foundation's reference implementation:

// build.gradle.kts
implementation("net.openid:appauth:0.11.1")
class AuthManager(private val context: Context) {
    private val authService = AuthorizationService(context)

    // Step 1: Launch authorization request
    fun authorize(activity: Activity) {
        val serviceConfig = AuthorizationServiceConfiguration(
            Uri.parse("https://auth.example.com/authorize"),    // auth endpoint
            Uri.parse("https://auth.example.com/token")         // token endpoint
        )

        val authRequest = AuthorizationRequest.Builder(
            serviceConfig,
            "your_client_id",
            ResponseTypeValues.CODE,
            Uri.parse("com.example.app://oauth/callback")  // redirect URI
        )
        .setScope("openid profile email")
        .build()  // PKCE code_challenge generated automatically

        val intent = authService.getAuthorizationRequestIntent(authRequest)
        activity.startActivityForResult(intent, AUTH_REQUEST_CODE)
    }

    // Step 2: Handle callback
    fun handleAuthResponse(intent: Intent, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
        val response = AuthorizationResponse.fromIntent(intent)
        val error = AuthorizationException.fromIntent(intent)

        if (error != null || response == null) {
            onError(error?.errorDescription ?: "Authorization failed")
            return
        }

        // Step 3: Exchange code for tokens
        val tokenRequest = response.createTokenExchangeRequest()
        authService.performTokenRequest(tokenRequest) { tokenResponse, tokenError ->
            if (tokenError != null || tokenResponse == null) {
                onError(tokenError?.errorDescription ?: "Token exchange failed")
                return@performTokenRequest
            }
            val accessToken = tokenResponse.accessToken ?: return@performTokenRequest
            onSuccess(accessToken)
        }
    }
}

Storing Tokens Securely

class TokenStore(context: Context) {
    private val encryptedPrefs = EncryptedSharedPreferences.create(
        context, "token_store", masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    var accessToken: String?
        get() = encryptedPrefs.getString("access_token", null)
        set(value) = encryptedPrefs.edit().putString("access_token", value).apply()

    var refreshToken: String?
        get() = encryptedPrefs.getString("refresh_token", null)
        set(value) = encryptedPrefs.edit().putString("refresh_token", value).apply()

    var tokenExpiry: Long
        get() = encryptedPrefs.getLong("token_expiry", 0)
        set(value) = encryptedPrefs.edit().putLong("token_expiry", value).apply()

    fun clearTokens() = encryptedPrefs.edit().clear().apply()

    val isAccessTokenValid: Boolean
        get() = accessToken != null && System.currentTimeMillis() < tokenExpiry - 60_000
}

Token Refresh with OkHttp Authenticator

class TokenRefreshAuthenticator(
    private val tokenStore: TokenStore,
    private val authService: AuthorizationService,
    private val authState: AuthState
) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        if (response.code != 401) return null

        // Refresh the access token
        val newToken = refreshTokenSynchronously() ?: return null  // return null = give up

        return response.request.newBuilder()
            .header("Authorization", "Bearer $newToken")
            .build()
    }

    private fun refreshTokenSynchronously(): String? {
        // Synchronous token refresh (OkHttp Authenticator runs on a background thread)
        var newToken: String? = null
        authState.performActionWithFreshTokens(authService) { accessToken, _, _ ->
            newToken = accessToken
            accessToken?.let { tokenStore.accessToken = it }
        }
        return newToken
    }
}

Custom Tab vs WebView

Always use Custom Tab for the authorization redirect — never WebView:

// ✅ Custom Tab — user can verify the URL, uses system's certificate store
// AppAuth handles this automatically with getAuthorizationRequestIntent()

// ❌ WebView — app can intercept credentials, can't verify URL authenticity
webView.loadUrl(authorizationUrl)  // NEVER do this for auth

Key Takeaways

ConceptRule
PKCERequired for all mobile OAuth flows — eliminates client_secret
Custom TabUse for auth redirects; never WebView
Token storageEncrypted SharedPreferences or Keystore; never plain prefs
Token refreshOkHttp Authenticator handles transparent refresh
Redirect URIUse app scheme (not https) for mobile: com.example.app://callback
Token expiryCheck and refresh before making API calls; keep 60s buffer

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
OAuth2/OIDC on Mobile | Android System Design | Android Engineers