Passkeys on Android: What the Docs Don't Tell You
We had passkeys working on web first. The RPID was set to our company’s TLD, the WebAuthn flows were tested, credentials were syncing through Google Password Manager. Then we started the Android implementation and found two things that cost us more time than they should have. This post covers both so you don’t have to find them yourself.
Getting the infrastructure right
The .well-known hosting problem
Passkeys on Android require assetlinks.json at https://<your-rpid>/.well-known/assetlinks.json. If your RPID is the company TLD — say, example.com — that file must live on the root domain. Not a subdomain. Not behind a CDN path that rewrites it. The exact domain.
This sounds obvious until your DevOps team explains that nobody actually deploys directly to the root domain. It’s fronted by a load balancer, a CDN layer, or legacy infrastructure that’s been running untouched for years and nobody wants to be the one who breaks it. Apple needs the same domain for their apple-app-site-association file, so you’re having this conversation twice.
Start it early. Before you write a line of Android code, confirm that your infra team can serve an arbitrary JSON file at /.well-known/ on your RPID domain. It blocks everything else.
The undocumented handle_all_urls requirement
This is the one that cost us the most time.
Google’s official documentation states that for passkey sharing between Android and web, you only need the get_login_creds delegation in your assetlinks.json. In practice, that’s not true.
Here’s what the docs say is sufficient:
[
{
"relation": ["delegate_permission/common.get_login_creds"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AA:BB:CC:..."]
}
}
]
Here’s what actually works:
[
{
"relation": [
"delegate_permission/common.handle_all_urls",
"delegate_permission/common.get_login_creds"
],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AA:BB:CC:..."]
}
}
]
Without handle_all_urls, Android fails to verify the RPID and throws GetPublicKeyCredentialDomException. It’s not silent — you do get an exception — but the error message doesn’t point you toward assetlinks.json. You end up questioning your WebAuthn server implementation, your Credential Manager integration, your certificate fingerprint — everything except the two lines in a JSON file.
To make it worse, the Statement List Generator — Google’s own tool for building assetlinks.json — only outputs get_login_creds. It will validate successfully and tell you everything looks correct, while your passkey flows keep throwing GetPublicKeyCredentialDomException.
Add both relations from day one.
The Android implementation
Android’s Credential Manager API is the right abstraction here — it unifies passkeys, passwords, and federated auth under one interface. Here’s the core of both flows.
Registration — creating a passkey:
val createRequest = CreatePublicKeyCredentialRequest(
requestJson = serverChallengeJson // PublicKeyCredentialCreationOptions JSON from your server
)
val result = try {
credentialManager.createCredential(
context = requireActivity(),
request = createRequest
) as CreatePublicKeyCredentialResponse
} catch (e: CreateCredentialCancellationException) {
// User dismissed the prompt — treat as a soft cancel, not an error
return
} catch (e: CreateCredentialException) {
// Surface an error to the user
return
}
// result.registrationResponseJson goes to your server's /register/finish endpoint
Authentication — signing in with a passkey:
val getRequest = GetCredentialRequest(
credentialOptions = listOf(
GetPublicKeyCredentialOption(
requestJson = serverChallengeJson // PublicKeyCredentialRequestOptions JSON
)
)
)
val result = try {
credentialManager.getCredential(
context = requireActivity(),
request = getRequest
)
} catch (e: GetCredentialCancellationException) {
return
} catch (e: NoCredentialException) {
// No passkey registered for this user — route to password auth
showPasswordFlow()
return
} catch (e: GetCredentialException) {
return
}
val credential = result.credential as? PublicKeyCredential ?: return
// credential.authenticationResponseJson goes to /auth/finish
The exception worth calling out: NoCredentialException. This isn’t an error in the traditional sense — it means the user doesn’t have a passkey registered yet. Catch it separately and route to your password flow. If you let it fall through to a generic error handler, you’ve just made your sign-in screen feel broken for every user who hasn’t created a passkey yet, which at launch is everyone.
Checking passkey availability before showing the prompt (Android 14+)
On Android 14+, CredentialManager exposes prepareGetCredential, which lets you check whether the user has any usable credentials before committing to showing the sign-in UI. It runs the credential lookup in the background without displaying anything, so you can gate your passkey entry point on an actual result rather than guessing.
// Requires API 34+ — call this early, e.g. in onResume or when the screen loads
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
suspend fun checkPasskeyAvailability() {
val prepareResult = credentialManager.prepareGetCredential(
GetCredentialRequest(
listOf(
GetPublicKeyCredentialOption(requestJson = serverChallengeJson)
)
)
)
if (prepareResult.hasAuthenticationResults()) {
// User has a passkey — show "Sign in with passkey" prominently
showPasskeySignInButton()
} else {
// No credentials found — go straight to the password field
showPasswordFlow()
}
}
When the user actually taps sign-in, pass the prepareResult to getCredential to avoid doing the lookup twice:
val result = credentialManager.getCredential(
context = requireActivity(),
pendingGetCredentialHandle = prepareResult.pendingGetCredentialHandle!!
)
This is particularly useful for the migration scenario: existing users who haven’t created a passkey yet see the password flow immediately, while users who have one see the passkey prompt without an unnecessary extra tap.
Autofill on username and password fields
Starting with androidx.credentials:1.5.0-alpha01, you can attach a GetCredentialRequest directly to individual views. When the user taps a username or password field, Credential Manager runs the request and surfaces matching credentials in the keyboard inline suggestions or autofill dropdown — the same result as calling getCredential explicitly, but triggered by field focus rather than a button tap.
val getCredRequest = GetCredentialRequest(
listOf(
GetPublicKeyCredentialOption(requestJson = serverChallengeJson)
)
)
// Attach to both fields so credentials appear whichever the user taps first
usernameEditText.pendingGetCredentialRequest = PendingGetCredentialRequest(
getCredRequest
) { response ->
handleSignIn(response)
}
passwordEditText.pendingGetCredentialRequest = PendingGetCredentialRequest(
getCredRequest
) { response ->
handleSignIn(response)
}
The response handler is identical to what you’d write for a regular getCredential call. pendingGetCredentialRequest is an extension property from the androidx.credentials library, not an Android framework API.
The harder problem: UX
The technical side is mostly solvable. The UX problem is where I’d actually ask for help, because I don’t think anyone has fully figured it out.
Passkeys work on a fundamentally different mental model than passwords. For new users — people who create an account for the first time after you ship passkeys — this is fine. They have no prior expectations. They tap their fingerprint, they’re in, and they don’t particularly need to understand what happened.
For existing users who already have a password, the migration is where things get genuinely complicated.
Explaining what a passkey is. “Sign in with your fingerprint” is understandable. “Create a passkey” is not. Users don’t know the word. They don’t know where the passkey is stored. They don’t know what happens if they get a new phone, or if they switch from Android to iPhone. These are reasonable questions and if your UI can’t answer them briefly and reassuringly, users will skip passkey creation every time. You have about two sentences of attention.
Managing two auth paths simultaneously. You cannot remove password auth on the day passkeys ship. Your existing users need their existing login method to keep working. So now you have two authentication paths, and every entry point into your app carries an implicit question: do we show the passkey prompt first? The password field? A choice between the two? There’s no universal right answer, but you need one consistent answer across your entire app. Inconsistency here is worse than either option — it makes the product feel unreliable.
The migration prompt. When do you ask an existing, password-authenticated user to create a passkey? Post-login is the most natural moment — they’ve just proven their identity, the session is warm, the trust is established. But “would you like to set up passkeys?” right after someone successfully logged in can feel like a pop-up that escaped from 2012. Show it too often and users learn to dismiss it without reading. Build in a “don’t ask again” option and you’ve permanently surrendered a user’s upgrade path. Think about this less as a single prompt and more as a campaign with a cadence — how many times, at what intervals, triggered by what actions.
For new user registration, the experience is relatively clean. Passkeys are genuinely better and most new users will accept them if you present the creation step clearly. The challenge is almost entirely in the migration path for people who are already using your app.
Sample project
If you want something to run and poke at, I put together a passkey-sample repo that covers the full stack: Android app (Kotlin + Credential Manager), a web frontend, and a Firebase Functions backend. It’s a useful reference if you’re wiring up the same pieces and want to see how they connect end-to-end.
Where this leaves us
Passkeys are the right direction. Phishing-resistant, no shared secrets, biometric-backed — the security model is better than passwords in every measurable way, and the authentication experience for users who have set one up is noticeably faster and simpler than typing a password.
But “better” and “easy to ship well” aren’t the same thing. The infrastructure coordination is a real project management challenge, not just a technical one. The handle_all_urls gap in the documentation will hopefully get fixed, but until it does, assume you need both relations — the GetPublicKeyCredentialDomException you’d otherwise get is not obviously caused by a missing relation. And the UX design problem — particularly the migration experience for existing users — is largely unsolved territory. If you find something that works well, write it up. The ecosystem needs more of that thinking.