Why are passkeys replacing passwords?
Setting Up Credential Manager
MutableContextWrapper: "To avoid a potential memory leak, pass in a MutableContextWrapper of your foreground Activity to the getCredential() call" (Android).
The server does the cryptography. Your backend issues the challenge and verifies the response, following WebAuthn. Do not write that verification yourself. Maintained libraries exist: SimpleWebAuthn for Node.js, webauthn-rs for Rust and java-webauthn-server for Java.// 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
CreatePublicKeyCredentialResponse, and its registrationResponseJson property is "the public key credential registration response in JSON format" (reference). 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 explains.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
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()".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
NoCredential: it is a navigation outcome, not a failure.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
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 and the Play Integrity guide.
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
- 1A passkey has no shared secret: the device holds the private key and the server stores only the public key.
- 2Passkeys through Credential Manager work on Android 9 (API level 28) and higher, and require Digital Asset Links.
- 3Create the CredentialManager with any context, and launch flows with an activity-based context wrapped in MutableContextWrapper.
- 4One getCredential request can offer passkeys and saved passwords. NoCredentialException is the sign-up path, not an error.
- 5WebAuthn requires challenges to be randomly generated on the server. Use a maintained server library to verify responses.
- 6Keep a password fallback, and make account recovery at least as strong as sign-in.
Frequently Asked
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.
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.