Engineering

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.

MobileByteSensei 11 min read
kmpplatformsecurity

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 suspendCancellableCoroutine learns 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.

A thousand scripted interruptions per scenario showing that every rotation during the prompt left the coroutine parked — neither resumed nor cancelled — while approvals and cancellations resumed normally

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.

Four ways the host dies measured against two candidate fixes, showing that a configuration-changes declaration covers rotation and folding while a system-initiated recreation and a locale change still park the coroutine

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.

Five outcomes the error codes already distinguished and the boolean discarded, with a lockout after repeated failures previously surfacing to the user as a cancelled payment

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.


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".

Follow by RSS