The biometric prompt belongs to the OS, and it can disappear mid-call
You suspend across a UI the system presents and may destroy without telling you.
TL;DR
- A shared
authenticate()suspending across the system biometric prompt. Rotate the device while it is showing and the coroutine never resumed and never cancelled — 1,000 of 1,000 scripted rotations, every one a spinner with no timeout that outlived the screen it belonged to. - The prompt is presented by the OS, hosted by an Activity or a view controller, and the OS is
allowed to destroy that host. Nothing in
suspendCancellableCoroutinelearns that it happened. - There are five outcomes, not two. Success, failure, user-cancel, no-enrolled-biometric and host-destroyed are different events with different correct responses, and the first design had room for two.
- What reading on buys: the five-outcome contract, the registration that cancels on host death, and why a timeout is the wrong fix even though it stops the spinner.
The case this post is about
A payment confirmation gated on biometrics, in shared code:
// commonMain/kotlin/auth/Biometrics.kt
expect class Biometrics {
/** Returns true if the user authenticated. */
suspend fun authenticate(reason: String): Boolean
}
// commonMain — the call site
suspend fun confirmPayment(amount: Money) {
_state.value = Confirming
if (biometrics.authenticate("Confirm payment of ${amount.format()}")) {
api.capture(amount)
_state.value = Done
} else {
_state.value = Cancelled
}
}
// androidMain — the actual that shipped
actual class Biometrics(private val activity: FragmentActivity) {
actual suspend fun authenticate(reason: String): Boolean =
suspendCancellableCoroutine { cont ->
val prompt = BiometricPrompt(activity, mainExecutor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(r: AuthenticationResult) =
cont.resume(true)
override fun onAuthenticationError(code: Int, msg: CharSequence) =
cont.resume(false)
}
)
prompt.authenticate(promptInfo(reason))
}
}
That is the documented pattern, it works, and it has one failure mode nothing in it addresses.
The symptom
A support report: "the confirm button spins forever and I have to force-quit". Rare, and
reproducible once we knew where to look — rotate the device while the prompt is showing, on a
build with android:configChanges not covering orientation:
# adb logcat, during the repro
D BiometricPrompt: Showing authentication prompt
D MainActivity : onDestroy
W BiometricFragment: Unable to start authentication. No fragment manager.
# … and nothing further. No callback, no exception, no cancellation.
The Activity was destroyed, the prompt went with it, and the callback that owned the resume was
gone. The coroutine was parked on a continuation nobody held.
Quantified against a thousand scripted interruptions:
resumed cancelled PARKED
user approves 998 0 2
user cancels 1000 0 0
rotation during the prompt 0 0 1000
low-memory kill of the host 0 0 1000 (process died anyway)
app backgrounded, then returned 994 0 6
A thousand rotations, a thousand parked coroutines. The low-memory row is the honest one — the process dies, so the park is moot — and the rotation row is not: the process lives, the screen comes back, and the user is looking at a spinner that will never stop.

Why the obvious fix failed
The first fix was a timeout, which removes the symptom:
withTimeoutOrNull(60.seconds) { biometrics.authenticate(reason) } ?: false
The spinner stops after sixty seconds, and sixty seconds is the wrong number in both directions at once. It is far too long for a destroyed host, where the information was available immediately — the user stares at a spinner for a minute to learn something the system knew instantly. And it is uncomfortably tight at the other end: we measured the distribution later and p99 of a successful authentication was 41 seconds, so the cut-off sits just above the tail rather than clear of it. A person who glances away, or whose device falls back to PIN entry, is inside the slice a 60-second timeout cancels — a small rate, and one made of genuine attempts.
More importantly, false is the wrong value. The caller's else branch sets Cancelled, which
tells the user they declined a payment they never saw a prompt for.
The second fix was to make the Activity survive rotation:
<activity android:configChanges="orientation|screenSize|keyboard" />
Which works for rotation and does nothing for the other ways a host dies — a system-initiated Activity recreation under memory pressure with the process surviving, a theme change, a locale change, a fold/unfold on a foldable. It is a fix for one trigger of a general mechanism, and it trades away correct configuration handling to get it.
The third attempt was to hold the continuation in the Activity's ViewModel so it would survive
recreation. A continuation is not survivable state — it references the coroutine's frame — and
after recreation there is no prompt to resume from. The prompt is gone; the user's
authentication genuinely did not complete.
The actual mechanism
Three lifetimes and a continuation, and the continuation is the only one with no owner.
The prompt's lifetime is the host's. BiometricPrompt needs a FragmentActivity because it
presents a fragment. On iOS, LAContext.evaluatePolicy presents system UI over the current
window. In both cases the OS may destroy that host at any point, and when it does the prompt is
dismissed without delivering a result.
The callback's lifetime is the prompt's. AuthenticationCallback is held by the prompt. No
prompt, no callback, no resume.
The continuation's lifetime is nobody's. suspendCancellableCoroutine registers a
continuation and waits for exactly one of two things: a resume, or a cancel. Neither is going
to arrive, and suspendCancellableCoroutine has no notion that the thing which was going to
resume it has ceased to exist. It is not stuck; it is correctly waiting forever for an event that
can no longer occur.
And the return type had room for two outcomes when there are five. This is the part the timeout fix cannot repair, because it is a modelling error rather than a lifecycle one:
success → proceed
user cancelled → return to the previous screen, no error
no enrolled biometric → fall back to a PIN; this is a SETUP state, not a failure
failed (lockout, hw) → a real error, with the system's own message
host destroyed → the attempt did not happen; the caller decides what to do
(A biometric MISMATCH is deliberately not on this list. The prompt keeps itself up and lets the
user retry, so a mismatch is not yet a result — see the fix, where resuming on it is the bug.)
A Boolean collapses the last four into false, and the correct response differs for every one.
"No enrolled biometric" in particular is not a refusal — it means the feature is unavailable on
this device right now and the app should offer the fallback it already has.

The fix
An application-scoped requester that holds a host registration, with the suspension cancelled when the registration goes away, and a sealed result with all five outcomes.
// commonMain/kotlin/auth/Biometrics.kt
sealed interface AuthOutcome {
data object Success : AuthOutcome
data object UserCancelled : AuthOutcome
data object NotEnrolled : AuthOutcome // offer the PIN fallback
data class Failed(val reason: String) : AuthOutcome // a real error worth showing
}
/**
* Host death is a CancellationException rather than an outcome, deliberately: the attempt did
* not happen, so there is no result to report. A caller that wants to re-present on return
* catches it; a caller that does not gets structured-concurrency cancellation, which is the
* correct default.
*/
class HostGoneException : CancellationException("biometric host destroyed mid-prompt")
expect class Biometrics {
suspend fun authenticate(reason: String): AuthOutcome
}
// androidMain/kotlin/auth/Biometrics.android.kt
//
// Application-scoped. The HOST attaches and detaches itself, which inverts the original
// design: the requester no longer holds an Activity that may be dead, it holds a
// registration that the Activity removes on destroy.
actual class Biometrics internal constructor() {
private class Host(val show: (String, BiometricPrompt.AuthenticationCallback) -> Unit)
private var host: Host? = null
private var pending: CancellableContinuation<AuthOutcome>? = null
fun attach(activity: FragmentActivity) {
host = Host { reason, cb ->
BiometricPrompt(activity, activity.mainExecutor, cb).authenticate(promptInfo(reason))
}
activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
// The one line the original design was missing. End the suspension instead
// of abandoning it: the caller gets a cancellation it can handle, and the
// spinner stops because the coroutine actually completes.
pending?.cancel(HostGoneException())
pending = null
host = null
}
})
}
actual suspend fun authenticate(reason: String): AuthOutcome {
// Enrolment is a QUERY, not a failure path. Asking first means NotEnrolled is
// returned without ever presenting a prompt, which is also faster.
when (BiometricManager.from(appContext)
.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) {
BIOMETRIC_ERROR_NONE_ENROLLED,
BIOMETRIC_ERROR_NO_HARDWARE -> return AuthOutcome.NotEnrolled
else -> Unit
}
// No host is a WIRING bug — distinct from a host that left mid-call, and it fails
// fast rather than registering a request nobody can deliver.
val h = host ?: error("Biometrics.authenticate() with no attached host — call attach()")
return suspendCancellableCoroutine { cont ->
pending = cont
cont.invokeOnCancellation { pending = null }
h.show(reason, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(r: AuthenticationResult) {
pending = null; cont.resume(AuthOutcome.Success)
}
override fun onAuthenticationError(code: Int, msg: CharSequence) {
pending = null
// The error CODE is the five-outcome distinction the Boolean lost.
cont.resume(when (code) {
ERROR_NEGATIVE_BUTTON, ERROR_USER_CANCELED, ERROR_CANCELED ->
AuthOutcome.UserCancelled
ERROR_NO_BIOMETRICS, ERROR_HW_NOT_PRESENT ->
AuthOutcome.NotEnrolled
else -> AuthOutcome.Failed(msg.toString())
})
}
// onAuthenticationFailed is a MISMATCH, not a result: the prompt stays up
// and the user retries. Resuming here would dismiss a live prompt.
})
}
}
}
And the call site, which now has five branches and one of them is a cancellation:
// commonMain
suspend fun confirmPayment(amount: Money) {
_state.value = Confirming
try {
when (val r = biometrics.authenticate("Confirm payment of ${amount.format()}")) {
AuthOutcome.Success -> { api.capture(amount); _state.value = Done }
AuthOutcome.UserCancelled -> _state.value = Idle // not an error
AuthOutcome.NotEnrolled -> _state.value = NeedsPinFallback // not a refusal
is AuthOutcome.Failed -> _state.value = Error(r.reason)
}
} catch (e: HostGoneException) {
// The attempt did not happen. Return to a state the user can retry from — and
// deliberately do NOT re-present automatically: a prompt appearing unbidden after
// a rotation is its own bug.
_state.value = Idle
}
}
And the output it produced, the same thousand scripted interruptions:
$ ./gradlew :auth:interruptionDrill
resumed cancelled PARKED
user approves 1000 0 0
user cancels 1000 0 0
rotation during the prompt 0 1000 0
app backgrounded, then returned 1000 0 0
no enrolled biometric 1000 0 0 (prompt never shown)
parked coroutines: 0 / 5,000
How we proved it
That the coroutine was parked and not slow — the distinction that a timeout obscures:
$ # kotlinx-coroutines-debug, installed in the debug build: DebugProbes.dumpCoroutines()
$ adb logcat -s CoroutineDump | grep -A2 'authenticate'
Coroutine "confirm-payment#4812", state: SUSPENDED
at auth.Biometrics.authenticate(Biometrics.android.kt:48)
running for: 14m 22s
Fourteen minutes suspended at the suspendCancellableCoroutine frame. Not a long operation — a
coroutine waiting for an event that can no longer occur.
That host death is a general mechanism and rotation is one trigger. The configChanges fix is what made this worth measuring, because it appears to work:
parked, before with configChanges with the registration
rotation 1000 0 0
system-initiated recreation 1000 1000 0
locale change 1000 1000 0
fold/unfold 1000 0 0
Two of the four triggers survive configChanges, which is why it reads as a fix and is not one.
That the five outcomes are distinguishable at the source. The error codes were already there;
the Boolean discarded them. Measured across the drill:
BiometricPrompt error code mapped to count
ERROR_NEGATIVE_BUTTON UserCancelled 412
ERROR_USER_CANCELED UserCancelled 588
ERROR_NO_BIOMETRICS NotEnrolled 1000
ERROR_LOCKOUT Failed 37
ERROR_HW_UNAVAILABLE Failed 11
ERROR_LOCKOUT is the one worth naming: too many failed attempts, the device locks biometrics for
30 seconds, and under the old design that surfaced as "payment cancelled" with no explanation. It
is now Failed with the system's own message.
That the enrolment pre-check is a query and not a shortcut. The fixture is a device with no enrolled fingerprint, and the assertion is that no prompt is presented at all:
$ ./gradlew :auth:connectedAndroidTest --tests '*NotEnrolledShowsNoPrompt*'
PASSED AuthOutcome.NotEnrolled returned in 8ms, 0 prompts presented
Eight milliseconds and no UI, against presenting a prompt that immediately errors — which is what the original did, and it flashed a dialog on screen before falling back.
That onAuthenticationFailed must not resume. A deliberately-broken fixture that resumes on a
mismatch, which dismisses a prompt the user is still using:
$ ./gradlew :auth:connectedAndroidTest --tests '*MismatchKeepsPromptUp*'
FAILED (with the broken variant): prompt dismissed after 1 mismatch;
expected 3 retry attempts available
What I cannot tell you is the real-world rate. A thousand scripted rotations is not a thousand user rotations, and the support reports were rare enough that I have no reliable denominator. The interruption drill proves the mechanism and the fix; it says nothing about how often users rotate their phones mid-payment, and I would guess rarely.
And an honest gap. HostGoneException tells the caller the attempt did not happen. It does not
restore the user's intent — after recreation they are back on the confirmation screen with a button
to press again. Re-presenting automatically was considered and rejected: a biometric prompt
appearing without a user action, immediately after the screen has reappeared, reads as an attack.
So the user does repeat one tap, and that is a product decision rather than a limitation of the
mechanism.

What this generalizes to
Any suspension that crosses into system-presented UI is a conversation with a host the operating system is entitled to destroy, and the default behaviour of a continuation in that situation is to wait forever — which is correct, local, and useless. Every such bridge needs an answer to what happens if the thing holding the callback dies, and the answer has to be a cancellation the caller can see rather than a timeout that guesses. The same shape covers share sheets, document pickers, camera intents, in-app review and billing flows: the prompt is not yours, its host is not yours, and the only part you control is whether your code finds out when it goes away.
Related
- A platform bridge is a conversation with a host that can leave — the same lifetime problem for permissions, where the OS behaviour is identical and the fallback differs.
- There is no shared way to store a secret — what the authentication actually unlocks, and why the key never enters shared code.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".