ANDROID-ARCHITECT

AI-powered Android development assistant.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:37:15
UP00:01
MOD · ARTICLE · SECURITYS/N · AX-CREDENPUBLISHED
SecurityApr 7, 202614 MIN

Implementing Passkeys and Biometric Auth in Android with Credential Manager

Passkeys replace passwords with phishing-resistant, device-bound credentials. Credential Manager unifies passkeys, passwords, and federated sign-in in one API.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Why Passkeys Are Replacing Passwords

Passwords are the weakest link in application security. Credential stuffing attacks exploit the fact that 65% of users reuse passwords across services — a single breach exposes accounts on every service sharing that password. Phishing attacks trick users into entering credentials on fake login pages. Even strong, unique passwords require a password manager that most users do not use. The fundamental problem is that passwords are shared secrets: both the user and the server know the credential, and any interception — in transit, at rest, or through social engineering — compromises the account. Passkeys eliminate shared secrets entirely. A passkey is a FIDO2/WebAuthn credential pair: a private key that never leaves the user's device (or their platform account's secure enclave) and a public key stored on the server. Authentication works by cryptographic challenge-response: the server sends a random challenge, the device signs it with the private key after biometric verification, and the server validates the signature with the public key. The private key is never transmitted, never stored on the server, and cannot be phished — even a perfect replica of the login page cannot extract it because the credential is cryptographically bound to the legitimate origin. Google's Credential Manager API is the unified Android surface for passkeys, saved passwords, and federated sign-in (Sign in with Google). Launched in Android 14 and backported to Android 9+ via Google Play Services, it replaces the fragmented landscape of SmartLock, Autofill, and FIDO2 APIs with a single, consistent interface. For developers, this means one API call handles passkey creation, passkey authentication, password autofill, and Google Sign-In — the system presents the best available credential to the user automatically.

Setting Up Credential Manager

Credential Manager requires three dependencies: the core library, the Play Services integration for passkey support on pre-Android-14 devices, and the Kotlin coroutines adapter. The API is fully suspend-function-based, designed for structured concurrency. The `CredentialManager` instance is created from the activity context. All credential operations require an activity context (not application context) because the system needs to display the credential selection bottom sheet to the user. In a Hilt-injected architecture, provide it from the Activity scope. Before implementing passkey registration or authentication, your backend must support the WebAuthn protocol. Specifically, it must generate registration options (a challenge, relying party info, user info) and authentication options (a challenge, allowed credentials), and it must validate the signed responses. Libraries like SimpleWebAuthn (Node.js), webauthn-rs (Rust), or java-webauthn-server (Java) handle the cryptographic validation server-side. The critical security requirement: challenges must be generated server-side, unpredictable (cryptographically random), single-use, and time-limited. Never generate challenges on the client. The challenge is what prevents replay attacks — reusing or predicting a challenge allows an attacker to forge authentication responses.
kotlin
// build.gradle.kts (app module)
dependencies {
    implementation("androidx.credentials:credentials:1.5.0")
    implementation("androidx.credentials:credentials-play-services-auth:1.5.0")
    implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1")
}

// AuthRepository.kt
class AuthRepository @Inject constructor(
    @ActivityContext private val context: Context,
    private val api: AuthApi,
) {
    private val credentialManager = CredentialManager.create(context)

    // We'll use this in the registration and authentication sections below
}

Passkey Registration: Creating Credentials

Passkey registration is a two-step flow: your backend generates registration options (the challenge and relying party configuration), then your app passes these to Credential Manager which handles the biometric prompt, key generation, and attestation. The `CreatePublicKeyCredentialRequest` takes a JSON string matching the WebAuthn PublicKeyCredentialCreationOptions schema. Your backend generates this JSON, including the challenge (base64url-encoded), the relying party ID (your domain), the user's ID and display name, and the supported algorithm preferences (typically ES256). The app passes this JSON to Credential Manager verbatim. Credential Manager presents a system bottom sheet asking the user to confirm passkey creation with their biometric (fingerprint, face) or device lock. On success, it returns a `CreatePublicKeyCredentialResponse` containing the attestation object and client data JSON — both of which must be sent to your backend for validation and storage. The backend extracts the public key from the attestation and associates it with the user's account. Error handling matters for production quality. `CreateCredentialCancellationException` means the user dismissed the bottom sheet. `CreateCredentialProviderConfigurationException` means Google Play Services is unavailable or too old. `CreateCredentialException` with other types indicates platform-level failures. Your UI should handle each case gracefully: cancellation returns to the previous screen silently, configuration errors show a helpful message about updating Play Services, and other errors offer a retry with a fallback to password-based registration.
kotlin
suspend fun registerPasskey(userId: String): Result<Unit> {
    // Step 1: Get registration options from your backend
    val options = api.getRegistrationOptions(userId)

    // Step 2: Create the credential request
    val request = CreatePublicKeyCredentialRequest(
        requestJson = options.toJson() // WebAuthn PublicKeyCredentialCreationOptions JSON
    )

    return try {
        // Step 3: System shows biometric prompt + creates keypair
        val response = credentialManager.createCredential(
            context = context as Activity,
            request = request,
        )

        // Step 4: Send attestation to backend for validation
        val publicKeyResponse = response as CreatePublicKeyCredentialResponse
        api.verifyRegistration(
            userId = userId,
            attestation = publicKeyResponse.registrationResponseJson
        )
        Result.success(Unit)

    } catch (e: CreateCredentialCancellationException) {
        Result.failure(AuthError.UserCancelled)
    } catch (e: CreateCredentialProviderConfigurationException) {
        Result.failure(AuthError.PlayServicesUnavailable)
    } catch (e: CreateCredentialException) {
        Result.failure(AuthError.RegistrationFailed(e.message))
    }
}

Passkey Authentication: Signing In

Authentication follows the same two-step pattern: backend generates a challenge, app passes it to Credential Manager, user verifies with biometric, and the signed response goes back to the backend. The `GetCredentialRequest` can include multiple credential types simultaneously. This is the power of Credential Manager's unified API: a single request can offer passkey authentication, saved password autofill, AND Google Sign-In. The system presents the best available option to the user. If they have a passkey, it is shown first. If they only have a saved password, that is offered instead. If neither exists, Google Sign-In appears as a fallback. The user sees one bottom sheet with all their options — no need for your app to build a complex credential type selection UI. The passkey authentication request uses `GetPublicKeyCredentialOption` with the challenge JSON from your backend. The JSON includes the challenge and optionally an `allowCredentials` list that limits authentication to specific credential IDs (useful for re-authentication flows where you know which user is signing in). For initial sign-in with discoverable credentials, omit `allowCredentials` to let the system present all passkeys associated with your relying party. On success, the response contains the authenticator assertion: the signed challenge, the credential ID, and the client data. Send these to your backend, which validates the signature against the stored public key, verifies the challenge matches what it issued, and creates a session. The entire flow — from user tapping "Sign In" to authenticated session — typically takes under 3 seconds, most of which is the biometric verification animation.
kotlin
suspend fun signIn(): Result<AuthSession> {
    // Step 1: Get authentication challenge from backend
    val challenge = api.getAuthenticationOptions()

    // Step 2: Build request with multiple credential types
    val passKeyOption = GetPublicKeyCredentialOption(
        requestJson = challenge.toJson()
    )

    val googleIdOption = GetGoogleIdOption.Builder()
        .setFilterByAuthorizedAccounts(false)
        .setServerClientId(BuildConfig.GOOGLE_WEB_CLIENT_ID)
        .setAutoSelectEnabled(true)
        .build()

    val request = GetCredentialRequest.Builder()
        .addCredentialOption(passKeyOption)
        .addCredentialOption(googleIdOption)
        .build()

    return try {
        // Step 3: System shows unified credential picker
        val response = credentialManager.getCredential(
            context = context as Activity,
            request = request,
        )

        // Step 4: Handle the credential type returned
        when (val credential = response.credential) {
            is PublicKeyCredential -> {
                val session = api.verifyAuthentication(
                    assertion = credential.authenticationResponseJson
                )
                Result.success(session)
            }
            is CustomCredential -> {
                if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
                    val googleId = GoogleIdTokenCredential.createFrom(credential.data)
                    val session = api.verifyGoogleToken(googleId.idToken)
                    Result.success(session)
                } else {
                    Result.failure(AuthError.UnsupportedCredentialType)
                }
            }
            else -> Result.failure(AuthError.UnsupportedCredentialType)
        }

    } catch (e: GetCredentialCancellationException) {
        Result.failure(AuthError.UserCancelled)
    } catch (e: NoCredentialException) {
        Result.failure(AuthError.NoCredentialsAvailable)
    } catch (e: GetCredentialException) {
        Result.failure(AuthError.AuthenticationFailed(e.message))
    }
}

ViewModel Integration and UI Flow

The ViewModel bridges the credential operations with the Compose UI. Since Credential Manager requires an Activity context and shows system UI, the ViewModel exposes state and the Activity-scoped composable triggers the actual credential operations. The pattern that works cleanly with Compose: the ViewModel exposes a sealed interface of auth states (Idle, Loading, Success, Error) as a StateFlow. The composable observes this state and calls ViewModel methods that delegate to the AuthRepository. Because `createCredential` and `getCredential` are suspend functions, they integrate naturally with `viewModelScope.launch`. For the UI, Credential Manager handles the heavy lifting — the biometric bottom sheet, credential selection, and error display are all system-provided. Your composable needs only a sign-in button that triggers the flow and state handling for loading, success (navigate to home), and error (show message with retry). The entire auth screen can be under 100 lines of Compose code because the system UI does the complex work. One critical implementation detail: Credential Manager operations must be called from an Activity context, but your ViewModel should not hold an Activity reference (it would leak). The clean pattern is to pass the Activity as a parameter to the ViewModel method from the composable (which has access to `LocalContext.current`), or use a Hilt-provided `@ActivityContext` in the repository as shown above. Do not store the Activity reference — use it only for the duration of the credential operation.
kotlin
@HiltViewModel
class AuthViewModel @Inject constructor(
    private val authRepository: AuthRepository,
) : ViewModel() {

    sealed interface AuthState {
        data object Idle : AuthState
        data object Loading : AuthState
        data class Success(val session: AuthSession) : AuthState
        data class Error(val message: String, val canRetry: Boolean) : AuthState
    }

    private val _state = MutableStateFlow<AuthState>(AuthState.Idle)
    val state = _state.asStateFlow()

    fun signIn() {
        viewModelScope.launch {
            _state.value = AuthState.Loading
            authRepository.signIn().fold(
                onSuccess = { session -> _state.value = AuthState.Success(session) },
                onFailure = { error ->
                    _state.value = when (error) {
                        is AuthError.UserCancelled -> AuthState.Idle
                        is AuthError.NoCredentialsAvailable -> AuthState.Error(
                            "No saved credentials. Create an account first.",
                            canRetry = false
                        )
                        else -> AuthState.Error(
                            error.message ?: "Authentication failed",
                            canRetry = true
                        )
                    }
                }
            )
        }
    }

    fun registerPasskey(userId: String) {
        viewModelScope.launch {
            _state.value = AuthState.Loading
            authRepository.registerPasskey(userId).fold(
                onSuccess = { _state.value = AuthState.Idle }, // Passkey saved
                onFailure = { error ->
                    _state.value = AuthState.Error(
                        error.message ?: "Passkey registration failed",
                        canRetry = true
                    )
                }
            )
        }
    }
}

Security Considerations and Production Checklist

Passkeys provide strong authentication, but correct implementation requires attention to several security details that are easy to overlook. **Relying Party ID must match your domain exactly.** The RP ID is typically your app's associated domain (configured via Digital Asset Links in `.well-known/assetlinks.json`). A mismatch between the RP ID in your WebAuthn options and your actual domain will cause credential creation to silently fail on some devices. Verify your Digital Asset Links configuration is correct and accessible over HTTPS. **Challenge freshness is non-negotiable.** Server-generated challenges must be cryptographically random, single-use, and expire within 60-120 seconds. Store pending challenges in a server-side session or short-lived cache. Validate that the challenge in the response matches one you issued. Never accept a challenge you did not generate. **Attestation validation is optional but valuable.** The attestation object in the registration response can cryptographically prove that the credential was created by a genuine platform authenticator (not a software emulator). For most consumer apps, attestation verification is unnecessary complexity. For financial services, healthcare, or government applications, validate attestation to ensure credentials come from hardware-backed keystores. **Fallback authentication is required during transition.** Not all users will adopt passkeys immediately. Maintain password-based authentication alongside passkeys. Credential Manager handles this gracefully — the unified bottom sheet shows saved passwords alongside passkeys. Over time, prompt users to create passkeys when they sign in with passwords, gradually migrating the user base. **Account recovery must not bypass passkey security.** If your account recovery flow sends a magic link that directly creates a session without biometric verification, it negates the security benefit of passkeys. Recovery flows should require identity verification (email link + biometric, or SMS + biometric) and should create a new passkey for the recovered device rather than bypassing authentication entirely. **Test on real devices, not just emulators.** Passkey creation and biometric verification behave differently on emulators. The Android Emulator supports software-backed credentials, but the UX and error handling differ from hardware-backed credentials on physical devices. Test the complete flow on at least two physical devices with different form factors (phone and tablet) and different Android versions (14+ native, 9-13 via Play Services).
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Passkeys eliminate shared secrets — the private key never leaves the device, making phishing and credential stuffing impossible.
  2. 2Credential Manager unifies passkeys, saved passwords, and Google Sign-In into one API call with a system-provided bottom sheet.
  3. 3Registration is a two-step flow: backend generates WebAuthn options, Credential Manager handles biometric prompt and key generation.
  4. 4Authentication supports multiple credential types simultaneously — the system presents the best available option to the user.
  5. 5Server-side challenges must be cryptographically random, single-use, and time-limited. Never generate challenges on the client.
  6. 6Maintain password-based auth as a fallback during transition. Prompt users to create passkeys when they sign in with passwords.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

Why are passkeys more secure than passwords?

There is no shared secret to steal. The private key never leaves the device, which makes phishing and credential-stuffing attacks structurally impossible rather than merely harder.

Can I generate the WebAuthn challenge on the client?

No. Challenges must be cryptographically random, single-use, and time-limited, and they must be generated server-side. A client-generated challenge defeats the protocol.

Should I remove password login once passkeys ship?

Not during the transition. Keep password auth as a fallback and prompt users to create a passkey when they sign in with a password.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA