Passkeys at Scale on Android: An Error Taxonomy, an Eligibility Gate, and a Fallback You Can Trust
Passkeys at Scale on Android: An Error Taxonomy, an Eligibility Gate, and a Fallback You Can Trust Adding a passkey button is an afternoon of work. Keeping that button from stranding users on a six-year-old handset with
Passkeys at Scale on Android: An Error Taxonomy, an Eligibility Gate, and a Fallback You Can Trust
Adding a passkey button is an afternoon of work. Keeping that button from stranding users on a six-year-old handset with no screen lock, a half-updated Play services, and a custom OEM credential sheet is a quarter of work. This post is about the second job.
1. The ceremony, minimally
A passkey sign-in is a challenge-response exchange. Your backend hands out a one-time challenge, the device signs it with a private key that never leaves secure hardware, and your backend verifies the signature against the stored public key.
Two details in that flow cause most production pain:
- Step 2 has a TTL. If the user sits on the biometric sheet, takes a call, and returns, your challenge may be gone. That is a server-side failure that surfaces client-side as a generic error.
- Step 4 belongs to someone else. The prompt is rendered by the system and a credential provider you do not control. You get exceptions, not pixels.
2. Dependencies and the thinย seam
Keep Credential Manager behind a small interface. It makes the classification logic testable on the JVM, with no emulator in the loop.
// build.gradle.kts (module)
dependencies {
implementation("androidx.credentials:credentials:<latest>")
implementation("androidx.credentials:credentials-play-services-auth:<latest>")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:<latest>")
}
interface PasskeyGateway {
/** Returns the raw JSON response to forward to the server. */
suspend fun createPasskey(activity: Activity, requestJson: String): String
suspend fun signIn(activity: Activity, requestJson: String): String
}
class CredentialManagerGateway(
private val manager: CredentialManager,
) : PasskeyGateway {
override suspend fun createPasskey(activity: Activity, requestJson: String): String {
val result = manager.createCredential(
context = activity, // must be an Activity so the sheet can attach to a window
request = CreatePublicKeyCredentialRequest(requestJson),
) as CreatePublicKeyCredentialResponse
return result.registrationResponseJson
}
override suspend fun signIn(activity: Activity, requestJson: String): String {
val result = manager.getCredential(
context = activity,
request = GetCredentialRequest.Builder()
.addCredentialOption(GetPublicKeyCredentialOption(requestJson))
.build(),
)
val credential = result.credential as? PublicKeyCredential
?: throw IllegalStateException("Provider returned ${result.credential::class.simpleName}")
return credential.authenticationResponseJson
}
}
Failure mode: the wrong Context. Passing an application context compiles fine and then fails at the moment the sheet should appear. Thread an Activity through deliberately, and never cache it in a long-lived object.
3. Build an error taxonomy before you build aย UI
Credential Manager reports problems as a family of exception types, plus DOM-style errors wrapped inside some of them. If every call site writes its own catch, you will end up with a dozen subtly different behaviors. Decide once, in one function.
sealed interface PasskeyFailure {
/** User backed out. Not an error. */
data object Cancelled : PasskeyFailure
/** Worth exactly one more attempt. */
data class Transient(val cause: Throwable) : PasskeyFailure
/** This device or account cannot do passkeys right now. Fall back, remember why. */
data class Unavailable(val reason: String, val cause: Throwable) : PasskeyFailure
/** We do not recognise it. Fall back, but shout in telemetry. */
data class Unknown(val cause: Throwable) : PasskeyFailure
}
fun Throwable.toPasskeyFailure(): PasskeyFailure = when (this) {
is GetCredentialCancellationException,
is CreateCredentialCancellationException -> PasskeyFailure.Cancelled
is GetCredentialInterruptedException,
is CreateCredentialInterruptedException -> PasskeyFailure.Transient(this)
is NoCredentialException ->
PasskeyFailure.Unavailable("no_matching_credential", this)
is GetCredentialProviderConfigurationException,
is CreateCredentialProviderConfigurationException ->
PasskeyFailure.Unavailable("provider_misconfigured", this)
is GetCredentialUnsupportedException,
is CreateCredentialUnsupportedException ->
PasskeyFailure.Unavailable("unsupported", this)
is GetPublicKeyCredentialDomException ->
PasskeyFailure.Unavailable("dom_get:${domError.type}", this)
is CreatePublicKeyCredentialDomException ->
PasskeyFailure.Unavailable("dom_create:${domError.type}", this)
else -> PasskeyFailure.Unknown(this)
}
Failure modes that biteย here
- Treating cancellation as failure. A user who dismisses the sheet has made a decision. If you show an error toast or increment a failure counter, your dashboards will lie to you and your users will feel nagged.
-
A DOM exception you did not map. Some DOM errors only show up on specific OS versions or provider combinations. Because the reason string above includes the DOM errorโs
typestring, a new variant appears in telemetry the day it ships rather than hiding inside anelsebranch. Usetyperather thandomError::class.simpleName: it is a stable identifier, whereas class names are renamed by R8 in release builds and would corrupt your dashboards. -
Swallowing
CancellationException. This one is a Kotlin trap, not an Android one. A blanketcatch (e: Exception)inside a coroutine also catches structured-concurrency cancellation, and your screen keeps working after the user has left it.
// Wrong: breaks cooperative cancellation
try { gateway.signIn(activity, json) } catch (e: Exception) { fallback() }
// Right: rethrow cancellation, classify everything else
try {
gateway.signIn(activity, json)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Throwable) {
handle(e.toPasskeyFailure())
}
Note the subtlety: a user cancelling the sheet throws a Credential Manager cancellation type, while coroutine cancellation throws kotlinx.coroutines.CancellationException. They are different things and must go through different branches.
4. The eligibility gate: do not start flows you cannotย finish
The cheapest failure is the one that never happens. Before you offer a passkey, check what you can check, and record the reason when you decline.
sealed interface Eligibility {
data object Eligible : Eligibility
data class Ineligible(val reasonCode: String) : Eligibility
}
class EligibilityChecker(
private val context: Context,
private val keyguard: KeyguardManager,
private val googleApi: GoogleApiAvailability = GoogleApiAvailability.getInstance(),
private val requireSecureLock: Boolean = true,
) {
fun check(): Eligibility {
// A heuristic, not a law: the default provider needs a secure lock to store
// passkeys. Third-party providers and security keys may not. Keep this switchable
// and let your own telemetry tell you whether the gate costs you eligible users.
if (requireSecureLock && !keyguard.isDeviceSecure) {
return Eligibility.Ineligible("no_screen_lock")
}
// Below Android 14 the Jetpack library routes through Google Play services.
// From Android 14 the platform serves providers itself, so do not hard-gate there.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val status = googleApi.isGooglePlayServicesAvailable(context)
if (status != ConnectionResult.SUCCESS) {
return Eligibility.Ineligible("play_services_$status")
}
}
return Eligibility.Eligible
}
}
Failure mode: over-gating. Every hard gate is a decision to exclude users. A blanket Play services check would shut out devices without Google services on Android 14 and later, and a blanket screen-lock check can exclude people whose passkeys live in a third-party provider. Log the reason code and revisit the gates with real data.
Failure mode: probing the provider on the critical path. Finding out whether a user already has a passkey means a round trip into a provider process. On a fleet where most users have not enrolled yet, that is a latency tax on nearly every sign-in, paid to learn โnoโ. Credential Manager has no free-standing โdo I have one?โ call, but it does offer two real mechanisms. On Android 14 and later, prepareGetCredential can warm up the lookup and report whether candidates exist. Everywhere, setPreferImmediatelyAvailableCredentials(true) on the real request makes it fail fast with NoCredentialException instead of showing a sheet with nothing useful in it. Verify both against the library version you ship.
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
suspend fun hasPasskeyCandidates(
manager: CredentialManager,
requestJson: String,
telemetry: Telemetry,
budgetMs: Long = 400,
): Boolean = try {
kotlinx.coroutines.withTimeout(budgetMs) {
val request = GetCredentialRequest.Builder()
.addCredentialOption(GetPublicKeyCredentialOption(requestJson))
.build()
manager.prepareGetCredential(request)
.hasCredentialResults(PublicKeyCredential.TYPE_PUBLIC_KEY_CREDENTIAL)
}
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
telemetry.slowProvider() // persist this so the next launch skips the probe
false
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
// GetCredentialException, or anything unexpected a provider payload can trigger.
// A failed probe must never crash sign-in: report "no candidates" and log the cause.
telemetry.probeFailed(e)
false
}
TimeoutCancellationException is a subclass of CancellationException, which is why it is caught before the rethrow branch. The final catch (e: Exception) is deliberately broad: this is an optimisation, so any failure inside it should degrade to "no candidates" (with a log line) rather than take down the sign-in screen. Note that prepareGetCredential and PrepareGetCredentialResponse are API 34+, hence @RequiresApi. Pick the budget from your own latency histogram, not from a blog post, and persist the "slow provider" signal so the next launch does not pay again.
The probe returns false for both โno candidatesโ and โtimed out,โ so the timeout is recorded separately through telemetry.slowProvider(). A caller can use that signal to hand off with UseClassicFlow("slow_provider"), so that cohort shows up in the same fallback and completion metrics as every other reason.
5. The orchestrator: one place that owns theย fallback
sealed interface SignInResult {
data class Success(val session: Session) : SignInResult
/** Hand off to the classic flow, carrying why, so the two halves of the funnel can be joined. */
data class UseClassicFlow(val reason: String) : SignInResult
data object UserCancelled : SignInResult
/** A flow is already on screen. Ignore this trigger; do not start another path. */
data object AlreadyRunning : SignInResult
}
class PasskeySignIn(
private val eligibility: EligibilityChecker,
private val gateway: PasskeyGateway,
private val api: AuthApi, // begin/finish endpoints
private val telemetry: Telemetry,
) {
private val inFlight = Mutex()
suspend fun run(activity: Activity): SignInResult {
if (!inFlight.tryLock()) return SignInResult.AlreadyRunning // duplicate trigger: ignore
try {
when (val e = eligibility.check()) {
is Eligibility.Ineligible -> {
telemetry.gate(e.reasonCode)
return SignInResult.UseClassicFlow(e.reasonCode)
}
Eligibility.Eligible -> Unit
}
return attempt(activity, retriesLeft = 1)
} finally {
inFlight.unlock()
}
}
private suspend fun attempt(activity: Activity, retriesLeft: Int): SignInResult {
return try {
val options = api.beginSignIn() // includes server challenge
val assertion = gateway.signIn(activity, options.json)
SignInResult.Success(api.finishSignIn(options.id, assertion))
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: ChallengeExpired) {
// Server said the challenge timed out: a fresh begin() is cheap and safe.
if (retriesLeft > 0) attempt(activity, retriesLeft - 1) else SignInResult.UseClassicFlow("challenge_expired")
} catch (e: Throwable) {
when (val f = e.toPasskeyFailure()) {
PasskeyFailure.Cancelled -> SignInResult.UserCancelled
is PasskeyFailure.Transient -> {
telemetry.transient(f.cause)
if (retriesLeft > 0) attempt(activity, retriesLeft - 1)
else SignInResult.UseClassicFlow("transient_exhausted")
}
is PasskeyFailure.Unavailable -> {
telemetry.unavailable(f.reason)
SignInResult.UseClassicFlow(f.reason)
}
is PasskeyFailure.Unknown -> {
telemetry.unknown(f.cause)
SignInResult.UseClassicFlow("unknown")
}
}
}
}
}
The classic screen receives
reasonand reports how the handoff ended. This is what lets you measure whether the fallback actually works, not just that it was triggered.
// In the classic sign-in screen / ViewModel
fun onClassicSignInSucceeded(reason: String?) = reason?.let { telemetry.fallbackCompleted(it) }
fun onClassicSignInAbandoned(reason: String?) = reason?.let { telemetry.fallbackAbandoned(it) }
reasonis null when the user opens the classic flow directly, so those sign-ins stay out of the fallback metrics.
What this buys you:
- Never a dead end, and never a silent one. Every non-success, non-cancel branch lands on the classic flow with a reason attached. The user is not left on a spinner, and you can later tell whether they got through.
- Bounded retries. One retry for transient causes, never a loop. An infinite retry on a flaky provider is a battery drain with a login screen attached.
-
A lock against double invocation. A nervous double-tap, or a recomposition that relaunches the effect, would otherwise open two system sheets and race two
finishcalls against one challenge. The duplicate trigger is ignored (AlreadyRunning), not routed to the classic flow, otherwise the fallback screen would appear underneath a live passkey sheet.
6. Server-side shape: what the client must be able to relyย on
Whatever your backend language, the contract the client depends on is small, and the part that surprises mobile teams most is that an account can own more than one credential.
Why this matters to an Android engineer, not only a backend one:
- A user can legitimately hold several passkeys (a phone, a tablet, a replacement phone after a trade-in). A server that assumes one credential per account will either overwrite the old one silently or reject the new one. Both show up as โI set it up and it stopped working.โ
- Exclude lists prevent duplicate enrollment. When your begin-registration response lists the credential IDs the account already has, the device will not quietly mint a second passkey into the same provider. Make sure your client handles the resulting โalready registeredโ outcome as success-equivalent, not as an error.
- Lifecycle is a product decision. Too strict and you force pointless re-enrollment. Too lax and stale keys accumulate. Agree on the states and the cleanup cadence with the backend team before launch, not after the first support spike.
7. Creating the passkey: where to ask, and howย often
Technically, creation is the easy half. The hard half is when. A few practices that have held up well:
- Ask after a moment of demonstrated value, such as right after a successful sign-in or while the user is doing something security-relevant, not on first launch.
- Say what they get in one line: faster sign-in, no codes to wait for.
- Stop asking after a clear โnoโ. Persist the dismissal with a timestamp and back off.
- Run it as an experiment. Copy, placement, and timing move adoption more than any API tweak.
class PasskeyPromptPolicy(private val prefs: DataStore<Preferences>) {
private val lastDismissed = longPreferencesKey("passkey_prompt_dismissed_at")
suspend fun shouldPrompt(now: Instant): Boolean {
val dismissedAt = prefs.data.first()[lastDismissed] ?: return true
return Duration.between(Instant.ofEpochMilli(dismissedAt), now) > Duration.ofDays(30)
}
suspend fun recordDismissal(now: Instant) {
prefs.edit { it[lastDismissed] = now.toEpochMilli() }
}
}
As the systemโs own creation UI becomes more streamlined over OS releases, revisit your pre-prompt. A custom explainer that made sense when the platform flow was confusing can become redundant noise later.
8. Fragmentation: what to actuallyย test
Passkey behavior is a function of three things at once: the OS version, the lock-screen configuration, and the credential provider. You do not need to test the full cross product, but you do need to hit each axis deliberately.
- OS versions: at least one device on each major release you support, including the oldest one on which you enable passkeys.
- Lock configuration: none, PIN or pattern only, and biometric enrolled.
- Provider state: default provider present, provider disabled or signed out, and a third-party provider if your users commonly have one.
- Environment: airplane mode mid-flow, slow network, backgrounding during the biometric prompt, and a locked device resuming into your flow.
For the interrupted-prompt case, assert two things, not one. First, exactly one flow is active: the AlreadyRunning lock held and no second sheet opened. Second, the user can still finish signing in afterward. The first assertion catches races. The second separates a resilient fallback from a screen transition that merely hides the original failure.
Treat any bug that reproduces only on one OEM as a data point for your taxonomy, not a one-off patch. If the fix is โmap this new exception to Unavailableโ, the taxonomy absorbed it. If the fix is a special case at a call site, you have started to rot.
9. Observability: what toย graph
You cannot fix what you cannot see, and passkey flows fail quietly by design (they fall back). Instrument these:
-
Funnel counts per gate with reason codes (
no_screen_lock,play_services_<code>,slow_provider). - Outcome rates by bucket: success, cancelled, transient, unavailable, unknown.
-
Latency for the eligibility probe, the system sheet, and the server
finishcall, separately, with percentiles rather than averages. - Top DOM error classes by OS version and device model.
- Fallback rate, because a rising fallback rate with flat error rate means a device segment has quietly stopped being eligible.
- Fallback completion rate, grouped by the same reason codes. A fallback is a handoff, not a login. Track how many users routed to the classic flow actually complete it. A reason code with a high fallback rate and a low completion rate is where users are being stranded, and it is invisible if you only count the handoff.
Reason codes are the strings the orchestrator emits: challenge_expired, transient_exhausted, unknown, no_matching_credential, provider_misconfigured, unsupported, dom_get:<type>, dom_create:<type>, plus the gate codes above. The same code appears on the handoff and on the completion event, which is what lets you join the two halves of the funnel.
Alert on Unknown volume. It is your canary: a spike means the platform changed under you. Also alert on a drop in fallback completion for any single reason code. That means a device segment has fallen out of passkeys and out of your classic flow too, the worst combination.
10. Pre-launch checklist
- Exceptions funnel through one classifier with a
Cancelledbucket that is silent. -
CancellationExceptionis rethrown everywhere aThrowableis caught. - An
Activitycontext is used for every Credential Manager call. - Eligibility checks run before any prompt, always emit a reason code, and do not hard-gate on Play services from Android 14 onward.
- Provider probes use real APIs (
prepareGetCredentialor immediately-available credentials), have a latency budget, and persist a "too slow" signal. - Retries are bounded; the fallback path is always reachable.
- Fallback dashboards report completion of the classic flow per reason code, not just handoff counts.
- A mutex or equivalent prevents overlapping flows.
- The backend supports multiple credentials per account and an exclude list.
- Prompt cadence is experiment-driven and respects dismissal.
- Dashboards exist for gates, outcomes, latency, and unknown errors before the rollout starts.
๐ Closingย thought
Passkeys are not hard because of cryptography. The platform gives you that for free. They are hard because your app now participates in a conversation with the OS, a credential provider, and your own backend, and any of the three can say something you did not plan for. Classify failures once, filter ineligible users early, keep the old door open, and measure everything. Do that and the happy path gets to stay happy.
Further reading: the official passkeys guide and Credential Manager documentation on developer.android.com. Verify the exception class names and the latest artifact versions against the current release notes before copying code into production.
๐ฑ Crack Your Next Android Interview
If youโre targeting a senior Android role at a FAANG or top-tier MNC, syntax knowledge alone wonโt get you through the loop. I highly recommend โCracking Top MNC Android Interviewsโโโโa senior-level playbook covering framework internals, Jetpack Compose, coroutines, Android-specific system design, and DSA reframed around real mobile constraints, to help you walk into bar-raiser rounds ready.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover.
Originally published on Medium.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.


