Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 055 / 100

Authentication in Android apps: best practices explained

Login flows that work.

Expona Admin7 min read

Authentication in Android apps: best practices explained

Most teams ship authentication the hard way—managing passwords manually, storing tokens in SharedPreferences, and fighting with session expiry bugs in production. We've learned through painful iterations that the right approach combines modern platform APIs with sensible token management.

Why authentication matters on Android

Authentication is rarely the feature users praise, but it's the foundation everything else depends on. A broken login flow means users can't access your app. Poor token handling creates security vulnerabilities that scale across your user base. The good news is that Android now provides tooling that makes secure authentication straightforward if you follow the patterns we'll walk through here.

Credential Manager for passwords and passkeys

Google's Credential Manager API (part of androidx.credentials) replaces the deprecated SmartLock and gives you a unified interface for passwords and passkeys. It handles the device-level credential store, reducing what your app needs to manage directly.

When a user logs in, you call credentialManager.getCredential() with a GetPasswordOption or GetPasskeyOption. Android surfaces stored credentials to the user, and you receive back a credential object. Here's the flow:

kotlin
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.PasswordCredential
import androidx.credentials.exceptions.GetCredentialException

val credentialManager = CredentialManager.create(context)

suspend fun retrieveStoredCredential() {
    val request = GetCredentialRequest(
        credentialOptions = listOf(
            GetPasswordOption(),
            GetPasskeyOption()
        )
    )
    
    try {
        val result = credentialManager.getCredential(
            context = context,
            request = request
        )
        
        when (val credential = result.credential) {
            is PasswordCredential -> {
                val email = credential.id
                val password = credential.password
                // Send to your backend
                login(email, password)
            }
        }
    } catch (e: GetCredentialException) {
        // Handle cancellation or no credentials found
    }
}

After successful authentication, you save the credential for future use. This removes the friction of repeated password entry and signals to the system that your app supports secure credential storage.

kotlin
suspend fun saveCredentialAfterLogin(email: String, password: String) {
    val credential = PasswordCredential(
        id = email,
        password = password
    )
    
    val request = SaveCredentialRequest(
        credential = credential
    )
    
    try {
        credentialManager.saveCredential(
            context = context,
            request = request
        )
    } catch (e: SaveCredentialException) {
        // Handle save failure gracefully
    }
}

Passkeys are the future and eliminate passwords entirely. Supporting them now via Credential Manager prepares your app for the shift—users who've enrolled passkeys on their device can log in biometrically without typing anything.

Sign in with Google

Social login via Google reduces signup friction and outsources credential security to Google. Use the Google Identity Services library (identity4j or higher) rather than the old Google Sign-In.

Here's a practical example: a user taps "Sign in with Google," and you initiate the OAuth flow.

kotlin
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInClient
import com.google.android.gms.auth.api.signin.GoogleSignInOptions

class LoginViewModel(private val context: Context) : ViewModel() {
    
    private val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
        .requestIdToken(BuildConfig.GOOGLE_CLIENT_ID)
        .requestEmail()
        .build()
    
    private val googleSignInClient: GoogleSignInClient = 
        GoogleSignIn.getClient(context, gso)
    
    fun startGoogleSignIn() {
        val signInIntent = googleSignInClient.signInIntent
        // Launch this intent in your Activity/Fragment
    }
    
    fun handleGoogleSignInResult(task: Task) {
        try {
            val account = task.getResult(ApiException::class.java)
            val idToken = account?.idToken ?: return
            // Send idToken to your backend for verification
            verifyTokenWithBackend(idToken)
        } catch (e: ApiException) {
            // Handle sign-in failure
        }
    }
}

The key detail: get the ID token from the Google account and send it to your backend, where you verify it against Google's public keys. Never trust the client side to validate a token. Your server decodes the JWT, checks the signature, and issues your own session token.

Store tokens securely

After login, your backend returns an access token and (typically) a refresh token. These must be stored securely. SharedPreferences is not encrypted by default—use EncryptedSharedPreferences from androidx.security.crypto instead.

Here's a helper class for token storage:

kotlin
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

class SecureTokenStorage(context: Context) {
    
    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()
    
    private val encryptedPrefs = EncryptedSharedPreferences.create(
        context,
        "auth_tokens",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )
    
    fun saveAccessToken(token: String) {
        encryptedPrefs.edit().putString("access_token", token).apply()
    }
    
    fun saveRefreshToken(token: String) {
        encryptedPrefs.edit().putString("refresh_token", token).apply()
    }
    
    fun getAccessToken(): String? {
        return encryptedPrefs.getString("access_token", null)
    }
    
    fun getRefreshToken(): String? {
        return encryptedPrefs.getString("refresh_token", null)
    }
    
    fun clearAllTokens() {
        encryptedPrefs.edit().clear().apply()
    }
}

EncryptedSharedPreferences uses the Android Keystore to encrypt keys and values at rest. This protects tokens even if an attacker has physical access to the device. Always clear tokens on logout or when you detect an authentication error.

Refresh tokens in an interceptor

Access tokens have short lifespans (typically 15 minutes to 1 hour). When a token expires, you use the refresh token to get a new access token without forcing the user to log in again. The right place to handle this is in an OkHttp interceptor, so every API call checks and refreshes if needed.

kotlin
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route

class TokenAuthenticator(
    private val tokenStorage: SecureTokenStorage,
    private val authService: AuthService
) : Authenticator {
    
    override fun authenticate(route: Route?, response: Response): Request? {
        // Only refresh if we haven't already tried (prevent infinite loops)
        if (response.request.header("X-Retry") != null) {
            return null
        }
        
        val refreshToken = tokenStorage.getRefreshToken() ?: return null
        
        return try {
            val newAccessToken = authService.refreshAccessToken(refreshToken).blockingGet()
            tokenStorage.saveAccessToken(newAccessToken.accessToken)
            
            // Retry the original request with new token
            response.request.newBuilder()
                .header("Authorization", "Bearer ${newAccessToken.accessToken}")
                .header("X-Retry", "true")
                .build()
        } catch (e: Exception) {
            // Refresh failed; user needs to log in again
            tokenStorage.clearAllTokens()
            null
        }
    }
}

// Wire it into your OkHttpClient
val client = OkHttpClient.Builder()
    .authenticator(TokenAuthenticator(tokenStorage, authService))
    .addInterceptor { chain ->
        val originalRequest = chain.request()
        val accessToken = tokenStorage.getAccessToken()
        
        val requestWithAuth = if (accessToken != null) {
            originalRequest.newBuilder()
                .header("Authorization", "Bearer $accessToken")
                .build()
        } else {
            originalRequest
        }
        
        chain.proceed(requestWithAuth)
    }
    .build()

This setup means your API code never thinks about tokens. The interceptor automatically attaches the current token, and the authenticator handles refresh transparently. If refresh fails, we clear storage and the app can redirect to login.

Common mistakes

We've seen teams stumble in several ways:

  • Storing tokens in SharedPreferences unencrypted. Any app with debugging access can pull them out. Always use EncryptedSharedPreferences or Android Keystore.
  • Trusting social login tokens directly. Don't assume a valid JWT from Google is proof of user identity on your backend. Verify the signature server-side every time.
  • Implementing token refresh in the UI layer. Refresh should happen invisibly at the network layer. Putting it in your ViewModel or Activity creates race conditions and duplicated logic.
  • Not clearing tokens on logout. If you don't wipe the refresh token, an attacker with access to the device can still make authenticated requests after logout.
  • Ignoring Credential Manager because passkeys aren't ready yet. Credential Manager works with passwords now and will smoothly transition to passkeys. Starting with it keeps you aligned with Google's direction.

Key takeaways

  • Use Credential Manager to leverage the OS credential store for passwords and passkeys, reducing friction and strengthening security posture.
  • For social login, capture the ID token from Google Sign-In and verify it on the backend against Google's public keys.
  • Store access and refresh tokens in EncryptedSharedPreferences, never in plain SharedPreferences.
  • Implement token refresh in an OkHttp Authenticator so it happens transparently across all API calls without duplicating logic in your UI layer.
  • Always clear tokens on logout and handle refresh failures by returning the user to login rather than silently failing requests.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles