---
title: "Implementing Passkeys and Biometric Auth in Android with Credential Manager"
description: "Passkeys replace passwords with phishing-resistant public-key credentials. Credential Manager offers passkeys and saved passwords through one Android API."
url: https://android-x.com/blog/credential-manager-passkeys-android
author: "Rocky Elsalaymeh"
publisher: "Strategia-X (ANDROID-ARCHITECT)"
published: 2026-04-07
updated: 2026-10-11
category: "Security"
reading_time: "11 min read"
---

# Implementing Passkeys and Biometric Auth in Android with Credential Manager

> Passkeys replace passwords with phishing-resistant public-key credentials. Credential Manager offers passkeys and saved passwords through one Android API.

By Rocky Elsalaymeh. Published 2026-04-07, updated 2026-10-11. Canonical page: https://android-x.com/blog/credential-manager-passkeys-android

## Why are passkeys replacing passwords?

A password is a shared secret. The user knows it and the server stores something derived from it, so it can be stolen from either end: phished from the user or leaked from the server. A stronger password does not change that.

A passkey removes the shared secret. It is a WebAuthn public-key credential: the device keeps a private key and the server stores only the matching public key. Android's documentation states the two consequences. "Passkeys use public-key cryptography so that data breaches of service providers don't result in compromised accounts", and they "are based on industry standard APIs and protocols that are not subject to phishing attacks" ([Android](https://developer.android.com/identity/passkeys)).

On Android the API for this is [Credential Manager](https://developer.android.com/identity/sign-in/credential-manager). One request can offer the user their passkeys and their saved passwords together, in a sheet the system draws.

This article was rewritten in October 2026. The earlier version quoted a password-reuse percentage and a sign-in time with no source, linked two server libraries at addresses that do not exist, and said every credential call needs an Activity context. Those are corrected below.

## Setting Up Credential Manager

Two things must be true before any code runs. The create-passkeys guide puts them in one sentence: "Make sure that you have set up Digital Asset Links and that you target devices running Android 9 (API level 28) or higher" ([Android](https://developer.android.com/identity/passkeys/create-passkeys)). Digital Asset Links is how the platform confirms that your app and your domain belong to the same owner.

The dependencies are the core library and the Play services bridge. The current stable release is 1.6.0, dated 29 July 2026 ([releases](https://developer.android.com/jetpack/androidx/releases/credentials)). The guide's instruction is "Use the latest available versions of the dependencies", with a reason: releases before 1.2 throw on Android 14.

**Which context?** The earlier version of this article said every operation needs an Activity context. The documentation splits it. Creating the client is unrestricted: "Use your app or activity context to create a CredentialManager object." Launching a flow is different. The sign-in sample's comment says to "Use an activity-based context to avoid undefined system UI" behaviour, and wraps the activity in a `MutableContextWrapper`: "To avoid a potential memory leak, pass in a MutableContextWrapper of your foreground Activity to the getCredential() call" ([Android](https://developer.android.com/identity/passkeys/sign-in-with-passkeys)).

**The server does the cryptography.** Your backend issues the challenge and verifies the response, following [WebAuthn](https://www.w3.org/TR/webauthn-3/). Do not write that verification yourself. Maintained libraries exist: [SimpleWebAuthn](https://simplewebauthn.dev/) for Node.js, [webauthn-rs](https://github.com/kanidm/webauthn-rs) for Rust and [java-webauthn-server](https://github.com/Yubico/java-webauthn-server) for Java.

```kotlin
// build.gradle.kts (app module)
dependencies {
    implementation("androidx.credentials:credentials:1.6.0")
    implementation("androidx.credentials:credentials-play-services-auth:1.6.0")
}

class PasskeyClient(
    appContext: Context,
    private val api: AuthApi,
) {
    // An application context is fine for creating the client
    private val credentialManager = CredentialManager.create(appContext)

    // The flows below take the foreground Activity as a parameter
}
```

## Passkey Registration: Creating Credentials

Registration has three steps: the server sends creation options, the app asks Credential Manager to create the passkey, and the app sends the result back to the server.

The app does not build the options. It wraps what the server sent: "create a passkey by wrapping these options in a CreatePublicKeyCredentialRequest object and calling createCredential()". The response type is `CreatePublicKeyCredentialResponse`, and its `registrationResponseJson` property is "the public key credential registration response in JSON format" ([reference](https://developer.android.com/reference/kotlin/androidx/credentials/CreatePublicKeyCredentialResponse)). That string goes to your server unchanged.

Handle the failures the guide lists. Its `handleFailure` sample distinguishes, among others:

- `CreatePublicKeyCredentialDomException`: a passkey error defined by the WebAuthn specification.
- `CreateCredentialCancellationException`: the user chose not to continue. Return quietly.
- `CreateCredentialInterruptedException`: retryable.
- `CreateCredentialProviderConfigurationException`: the provider dependency is missing, which usually means `credentials-play-services-auth` was left out.

Catch `IOException` separately for the two network calls. A broad `catch (e: Exception)` in a suspend function also swallows cancellation, as the [coroutines deep dive](https://android-x.com/blog/kotlin-coroutines-deep-dive) explains.

```kotlin
suspend fun registerPasskey(activity: Activity, userId: String): AuthResult {
    return try {
        // 1. Creation options come from your server, challenge included
        val optionsJson = api.getRegistrationOptions(userId)

        // 2. The system creates the key pair after the user confirms
        val response = credentialManager.createCredential(
            context = activity,
            request = CreatePublicKeyCredentialRequest(requestJson = optionsJson),
        ) as CreatePublicKeyCredentialResponse

        // 3. The server verifies and stores the public key
        api.verifyRegistration(userId, response.registrationResponseJson)
        AuthResult.Success

    } catch (e: CreateCredentialCancellationException) {
        AuthResult.Cancelled
    } catch (e: CreateCredentialInterruptedException) {
        AuthResult.Retry
    } catch (e: CreateCredentialProviderConfigurationException) {
        AuthResult.Failed("Passkeys are not available on this device")
    } catch (e: CreateCredentialException) {
        AuthResult.Failed(e.message)
    } catch (e: IOException) {
        AuthResult.Retry
    }
}
```

## Passkey Authentication: Signing In

Sign-in mirrors registration: the server sends request options with a fresh challenge, the app asks Credential Manager for a credential, and the server verifies what comes back.

One request can carry several options. The guide's sample builds a `GetPasswordOption()` beside a `GetPublicKeyCredentialOption(requestJson = requestJson)`, so a user with a saved password and a user with a passkey are served by the same call. The response is then one of several types, and the code has to branch on it: a `PublicKeyCredential` carries `authenticationResponseJson` for the server, and a `PasswordCredential` carries an id and a password.

The case that surprises teams is the empty one: "If you call getCredential() but the user does not have any credentials available, or if they don't grant consent to using their credentials, a NoCredentialException is returned." Treat it as the path to sign-up or to a typed password, not as an error message.

On Android 14 and higher the guide offers one latency improvement: "you can reduce latency by showing the account selector by using the prepareGetCredential() method before calling getCredential()".

```kotlin
suspend fun signIn(activity: Activity): AuthResult {
    return try {
        // 1. Request options, with a fresh challenge, from your server
        val optionsJson = api.getAuthenticationOptions()

        val request = GetCredentialRequest(
            listOf(
                GetPasswordOption(),
                GetPublicKeyCredentialOption(requestJson = optionsJson),
            )
        )

        // 2. Use an activity-based context, wrapped to avoid a leak
        val result = credentialManager.getCredential(
            context = MutableContextWrapper(activity),
            request = request,
        )

        // 3. Branch on what the user chose
        when (val credential = result.credential) {
            is PublicKeyCredential ->
                api.verifyAuthentication(credential.authenticationResponseJson)
            is PasswordCredential ->
                api.signInWithPassword(credential.id, credential.password)
            else -> AuthResult.Failed("Unsupported credential type")
        }

    } catch (e: GetCredentialCancellationException) {
        AuthResult.Cancelled
    } catch (e: NoCredentialException) {
        AuthResult.NoCredential
    } catch (e: GetCredentialException) {
        AuthResult.Failed(e.message)
    } catch (e: IOException) {
        AuthResult.Retry
    }
}
```

## ViewModel Integration and UI Flow

A ViewModel must not hold an Activity, and the credential flows need one. The clean answer is to pass the Activity as a function parameter for the length of the call and store it nowhere. The composable has it; the ViewModel forwards it; the client wraps it.

The screen itself stays small, because the system draws the credential sheet. Your composable needs a button and a state to render. Model that state as one sealed type so that loading and error cannot be shown together, the choice discussed in [MVVM vs MVI](https://android-x.com/blog/mvvm-vs-mvi).

Note what the ViewModel does with `NoCredential`: it is a navigation outcome, not a failure.

```kotlin
sealed interface AuthUiState {
    data object Idle : AuthUiState
    data object Loading : AuthUiState
    data object SignedIn : AuthUiState
    data object NeedsSignUp : AuthUiState
    data class Error(val message: String, val canRetry: Boolean) : AuthUiState
}

@HiltViewModel
class AuthViewModel @Inject constructor(
    private val passkeys: PasskeyClient,
) : ViewModel() {

    private val _state = MutableStateFlow<AuthUiState>(AuthUiState.Idle)
    val state: StateFlow<AuthUiState> = _state.asStateFlow()

    // The Activity is used for this call and is not stored
    fun signIn(activity: Activity) {
        viewModelScope.launch {
            _state.value = AuthUiState.Loading
            _state.value = when (val result = passkeys.signIn(activity)) {
                AuthResult.Success -> AuthUiState.SignedIn
                AuthResult.Cancelled -> AuthUiState.Idle
                AuthResult.NoCredential -> AuthUiState.NeedsSignUp
                AuthResult.Retry ->
                    AuthUiState.Error("Check your connection", canRetry = true)
                is AuthResult.Failed ->
                    AuthUiState.Error(result.message ?: "Sign-in failed", canRetry = false)
            }
        }
    }
}
```

## Security Considerations and Production Checklist

**The challenge is the server's job.** The WebAuthn specification is explicit: "As a cryptographic protocol, Web Authentication is dependent upon randomized challenges to avoid replay attacks." The challenge values "MUST be randomly generated by Relying Parties in an environment they trust (e.g., on the server-side)". A challenge made on the device, or reused, defeats the protocol.

**Set up Digital Asset Links before debugging anything else.** It is a stated prerequisite. If the association between the app and the domain is wrong, passkey creation fails, and the cause is not in your Kotlin.

**Keep a second way in.** Not every user has a passkey and not every device can create one. The same `getCredential` call that offers passkeys offers saved passwords, so the fallback costs one extra option in the request. Offer passkey creation after a successful password sign-in.

**Do not let recovery undo the gain.** An account recovery path that hands out a session from a single emailed link is now the weakest way into the account, and attackers use the weakest way. This is our advice, not a platform rule: require a second proof in recovery, and have the user create a new passkey once they are back in.

**Decide trust on the server.** The device presents evidence and the server makes the decision. That principle, and how it combines with device integrity verdicts, is covered in [Android security practices](https://android-x.com/blog/android-security-best-practices) and the [Play Integrity guide](https://android-x.com/blog/play-integrity-api-android).

**Test on a physical device.** We have found the system sheet and its error paths worth exercising on real hardware, on both the oldest supported release, Android 9, and a current one.

## Key takeaways

- A passkey has no shared secret: the device holds the private key and the server stores only the public key.
- Passkeys through Credential Manager work on Android 9 (API level 28) and higher, and require Digital Asset Links.
- Create the CredentialManager with any context, and launch flows with an activity-based context wrapped in MutableContextWrapper.
- One getCredential request can offer passkeys and saved passwords. NoCredentialException is the sign-up path, not an error.
- WebAuthn requires challenges to be randomly generated on the server. Use a maintained server library to verify responses.
- Keep a password fallback, and make account recovery at least as strong as sign-in.

## Frequently asked questions

### Why are passkeys more secure than passwords?

A passkey has no shared secret. The device keeps a private key and the server stores only a public key, so a breach of the server does not expose anything that can sign in. Android describes passkeys as built on standard protocols that are not subject to phishing attacks, because the credential is bound to the genuine site.

### Can I generate the WebAuthn challenge on the client?

No. The WebAuthn specification says challenges must be randomly generated by the relying party in an environment it trusts, and gives the server side as the example. The protocol depends on randomized challenges to avoid replay attacks. Generate each one on your server, use it once, and check the response against it.

### Should I remove password login once passkeys ship?

Not yet. Passkeys through Credential Manager need Android 9 or higher, and many users will not have created one. A single getCredential request can offer saved passwords beside passkeys, so keeping the fallback is cheap. Invite users to create a passkey after they sign in with a password.

## Related articles

- [Securing Android Apps: OWASP Mobile Top 10 in Practice](https://android-x.com/blog/android-security-best-practices.md)
- [SafetyNet Is Gone. Play Integrity Is Only as Strong as Your Server](https://android-x.com/blog/play-integrity-api-android.md)
- [Dependency Injection with Hilt: The Complete Android Guide](https://android-x.com/blog/hilt-dependency-injection.md)
