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
| Concept | Rule |
|---|---|
| PKCE | Required for all mobile OAuth flows — eliminates client_secret |
| Custom Tab | Use for auth redirects; never WebView |
| Token storage | Encrypted SharedPreferences or Keystore; never plain prefs |
| Token refresh | OkHttp Authenticator handles transparent refresh |
| Redirect URI | Use app scheme (not https) for mobile: com.example.app://callback |
| Token expiry | Check and refresh before making API calls; keep 60s buffer |