Dev.to Security 🔐 Cybersecurity 👁 0 📖 8 min read

Behind the Prompt: What Android Really Does When Your App Asks “Is This You?”

A fingerprint unlock takes about a second. In that second your app hands off a request, a piece of secure hardware makes a decision you are not allowed to observe, and you get back a single verdict. Most apps treat that

Behind the Prompt: What Android Really Does When Your App Asks “Is This You?”

A fingerprint unlock takes about a second. In that second your app hands off a request, a piece of secure hardware makes a decision you are not allowed to observe, and you get back a single verdict. Most apps treat that verdict as the end of the story. Production incidents show it is the beginning of one.

This post walks through what the platform does, how to wire BiometricPrompt in idiomatic Kotlin, and the failure modes that tend to surface only after release. The running example is a fictional notes app, Lockbox, that keeps its database passphrase wrapped by a hardware-backed key.

1. The mental model: you ask, the platform answers

You never touch a fingerprint. You describe what you need, the system shows its own UI, and you receive an outcome.

five-stage-flow

Two consequences follow from this design:

  • Your process cannot see biometric data. There is no API that returns an image, a template, or a match score, and no flag that turns one on.
  • Your process cannot draw the prompt. The system UI owns it, which is what makes overlay spoofing hard.

trust-boundary

Everything that crosses the boundary on the way back is either a verdict or the authorised use of a key. That second item is the most important one in this article, and we will come back to it.

2. Choose the authenticator first

The most common integration bug is choosing the wrong authenticator class and discovering the consequences on a device you do not own.

authenticator-classes

Rules of thumb:

  • If a success should release a secret, require BIOMETRIC_STRONG.
  • If a success only dismisses a lock screen in your own UI, BIOMETRIC_WEAK is acceptable, and you accept that no cryptographic proof is available.
  • On API 28 and 29, combining BIOMETRIC_STRONG or DEVICE_CREDENTIAL throws IllegalArgumentException as soon as you call PromptInfo.Builder.build(), before authenticate() is ever reached. Branch on the SDK level.
object LockboxAuth {
    val authenticators: Int
        get() = BiometricManager.Authenticators.BIOMETRIC_STRONG

    // Only valid on API 30+; on 28-29 you must show your own fallback UI.
    val authenticatorsWithFallback: Int
        get() = if (Build.VERSION.SDK_INT >= 30) {
            BiometricManager.Authenticators.BIOMETRIC_STRONG or
                BiometricManager.Authenticators.DEVICE_CREDENTIAL
        } else {
            BiometricManager.Authenticators.BIOMETRIC_STRONG
        }
}

3. Ask before you prompt

Showing a button that can never succeed is a product bug. Query availability and model it as a type, not a boolean.

sealed interface Availability {
    data object Ready : Availability
    data object NotEnrolled : Availability
    data object NoSensor : Availability
    data object TemporarilyUnavailable : Availability
    data object SecurityUpdateRequired : Availability
    data object Unsupported : Availability
}

fun Context.biometricAvailability(authenticators: Int): Availability =
    when (BiometricManager.from(this).canAuthenticate(authenticators)) {
        BiometricManager.BIOMETRIC_SUCCESS -> Availability.Ready
        BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> Availability.NotEnrolled
        BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> Availability.NoSensor
        BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> Availability.TemporarilyUnavailable
        BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> Availability.SecurityUpdateRequired
        else -> Availability.Unsupported
    }

Re-evaluate this in onResume(). A user can enroll a finger, remove one, or disable the lock screen while your app sits in the background.

4. A callback is a claim; a key is proof

The naive integration shows the prompt and flips a flag in onAuthenticationSucceeded. It works on every demo device. It also means that anyone who can instrument your process on a rooted phone can call that method themselves.


keystore-bound-gate

The fix is to make success mean something cryptographically. Create an AES key that the hardware refuses to use until the user authenticates, and let the prompt unlock that key for a single operation.

private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "lockbox.passphrase.wrap"
private const val TRANSFORMATION = "AES/GCM/NoPadding"

private fun keyStore(): KeyStore =
    KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }

fun getOrCreateWrapKey(): SecretKey {
    (keyStore().getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }

    val spec = KeyGenParameterSpec.Builder(
        KEY_ALIAS,
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
    )
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
        .setKeySize(256)
        .setUserAuthenticationRequired(true)
        .setInvalidatedByBiometricEnrollment(true)
        .apply {
            if (Build.VERSION.SDK_INT >= 30) {
                // Timeout 0 = authenticate for every single cryptographic operation.
                setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
            }
        }
        .build()

    return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
        .apply { init(spec) }
        .generateKey()
}

fun encryptCipher(): Cipher =
    Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, getOrCreateWrapKey()) }

fun decryptCipher(iv: ByteArray): Cipher =
    Cipher.getInstance(TRANSFORMATION).apply {
        init(Cipher.DECRYPT_MODE, getOrCreateWrapKey(), GCMParameterSpec(128, iv))
    }

At enrollment, authenticate with encryptCipher(), call doFinal on the passphrase, and persist cipher.iv plus the ciphertext. At unlock, authenticate with decryptCipher(iv) and unwrap. If a hooked callback fires without the hardware's blessing, doFinal fails.

5. Wire the prompt to the lifecycle, not around it

BiometricPrompt needs a FragmentActivity or Fragment and observes its lifecycle (it drops your callback when the host is destroyed). Create it once, early, and forward results to a ViewModel. Do not build a new prompt inside a click listener, and do not capture the Activity in a coroutine that outlives it.

sealed interface UnlockState {
    data object Locked : UnlockState
    data object Unlocked : UnlockState
    data class TemporaryLockout(val message: String) : UnlockState
    data object NeedsPasswordLogin : UnlockState
    data object NeedsEnrollment : UnlockState
    data object KeyInvalidated : UnlockState
    data object Unavailable : UnlockState
    data class Failed(val message: String) : UnlockState
}

class UnlockViewModel(
    private val blobStore: WrappedPassphraseStore,
    private val vault: VaultRepository,
) : ViewModel() {

    private val _state = MutableStateFlow<UnlockState>(UnlockState.Locked)
    val state: StateFlow<UnlockState> = _state.asStateFlow()

    /** Returns null (and updates state) if the key can no longer be used. */
    fun prepareCipher(): Cipher? = try {
        decryptCipher(blobStore.read().iv)
    } catch (e: KeyPermanentlyInvalidatedException) {
        _state.value = UnlockState.KeyInvalidated
        null
    } catch (e: UnrecoverableKeyException) {
        _state.value = UnlockState.KeyInvalidated
        null
    }

    fun onBiometricSuccess(cipher: Cipher?) {
        if (cipher == null) {
            _state.value = UnlockState.Failed("No authorised cipher returned")
            return
        }
        viewModelScope.launch(Dispatchers.Default) {
            val passphrase = runCatching { cipher.doFinal(blobStore.read().ciphertext) }
            passphrase
                .onSuccess { bytes ->
                    try { vault.open(bytes) } finally { bytes.fill(0) }
                    _state.value = UnlockState.Unlocked
                }
                .onFailure { _state.value = UnlockState.Failed("Unlock failed") }
        }
    }

    fun onBiometricError(code: Int, message: CharSequence) {
        _state.value = when (code) {
            BiometricPrompt.ERROR_USER_CANCELED,
            BiometricPrompt.ERROR_NEGATIVE_BUTTON,
            BiometricPrompt.ERROR_CANCELED -> UnlockState.Locked

            BiometricPrompt.ERROR_LOCKOUT ->
                UnlockState.TemporaryLockout(message.toString())

            BiometricPrompt.ERROR_LOCKOUT_PERMANENT ->
                UnlockState.NeedsPasswordLogin

            BiometricPrompt.ERROR_NO_BIOMETRICS,
            BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> UnlockState.NeedsEnrollment

            BiometricPrompt.ERROR_HW_UNAVAILABLE,
            BiometricPrompt.ERROR_HW_NOT_PRESENT -> UnlockState.Unavailable

            else -> UnlockState.Failed(message.toString())
        }
    }
}
class UnlockActivity : FragmentActivity() {

    private val viewModel: UnlockViewModel by viewModels()
    private lateinit var prompt: BiometricPrompt

    private val promptInfo = BiometricPrompt.PromptInfo.Builder()
        .setTitle("Unlock Lockbox")
        .setSubtitle("Confirm it's you to open your notes")
        .setAllowedAuthenticators(LockboxAuth.authenticators)
        .setNegativeButtonText("Use password")   // required when no DEVICE_CREDENTIAL
        .setConfirmationRequired(false)          // skip the extra tap for face-style modalities
        .build()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Created in onCreate so a rotation re-attaches the callback to the live prompt.
        prompt = BiometricPrompt(
            this,
            ContextCompat.getMainExecutor(this),
            object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) =
                    viewModel.onBiometricSuccess(result.cryptoObject?.cipher)

                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) =
                    viewModel.onBiometricError(errorCode, errString)

                // onAuthenticationFailed() is intentionally not overridden: it is not terminal.
            },
        )
        // binding.unlockButton.setOnClickListener { startUnlock() }
    }

    private fun startUnlock() {
        if (biometricAvailability(LockboxAuth.authenticators) != Availability.Ready) return
        val cipher = viewModel.prepareCipher() ?: return
        prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
    }
}

6. Triage errors on purpose

onAuthenticationError is terminal. The prompt is gone and the session is over. Treat each code as a product decision instead of dumping errString into a toast.

error-triage

7. Failure modes that show up after release

These are the ones I would put on a pre-launch checklist.

  1. Enrollment invalidates the key. With setInvalidatedByBiometricEnrollment(true), adding a finger permanently kills the key. That is the correct security behavior and a terrible surprise if you never planned a re-enrollment path. Catch KeyPermanentlyInvalidatedException, delete the alias, and force a password login to re-wrap the secret.
  2. onAuthenticationFailed mistaken for failure. It fires on a bad read while the prompt stays open. Counting it as an attempt in your own lockout logic locks out users the system would have let retry.
  3. Backgrounding yields ERROR_CANCELED. If the user switches apps mid-prompt, you get a cancel you did not cause. Re-prompting automatically on onResume() creates an annoying loop, so wait for a deliberate tap.
  4. API 28–29 combination crash. BIOMETRIC_STRONG or DEVICE_CREDENTIAL is rejected there. Test on real or emulated API 29 images, not only on the latest.
  5. Missing negative button. Omitting setNegativeButtonText without DEVICE_CREDENTIAL throws at build(). Adding both throws too.
  6. Weak class with a CryptoObject. A BIOMETRIC_WEAK authenticator cannot unlock a Keystore key. If your code path assumes it can, you will see failures only on devices whose best sensor is Class 2.
  7. Rotation and process death. Prompts built inside click handlers lose their callbacks. Build in onCreate, route to the ViewModel, and never hold sensitive state (including the decrypted passphrase) in SavedStateHandle.
  8. Long-lived secrets in memory. Zero the passphrase ByteArray as soon as the database opens, as shown above. A strong lock screen is undermined by a secret that sits on the heap for an hour.
  9. Emulators lie. They are fine for flow testing but say nothing about Keystore behavior, lockout timing, or vendor quirks. Keep a small physical-device matrix (at least one Samsung, one Pixel, one low-end device).

8. Testing without a finger

  • Put the prompt behind a thin interface (Authenticator { fun authenticate(): Flow<AuthEvent> }) so unit tests can emit success, each error code, and cancellation.
  • On emulators, use Extended controls → Fingerprint to simulate touches and non-matching reads.
  • Write an instrumented test that creates the key, rotates the enrollment on a device farm, and asserts you land in KeyInvalidated.

Takeaways

  • You do not implement fingerprint recognition. You request an outcome from a system that keeps the sensitive parts away from you.
  • Decide the authenticator class by what a success unlocks, not by what is convenient.
  • A boolean success is a claim. Bind it to a hardware-backed key so it becomes proof.
  • Model errors as states. Cancel, lockout, missing enrollment and key invalidation each deserve a distinct experience.
  • Test on hardware you do not control, because that is where Keystore and vendor behavior live.

Your turn: which of the failure modes above has bitten your team, and how did you handle key invalidation after a user added a new finger? Share it in the comments.

📱 New to Jetpack Compose? Start Here

If you’re coming from the classic Android View system — XML layouts, findViewById, RecyclerView adapters — and want a clear, no-jargon path into Jetpack Compose, I recommend "Learning Jetpack Compose: Your Practical Guide from the Android View System to Modern Declarative UI." Every chapter starts from something you already know and shows its Compose equivalent side by side, building up to a full capstone: a real Notes app. Once you're comfortable and want to go deeper — the Composition tree, recomposition internals, the runtime itself — "Mastering Jetpack Compose Internals" is the natural next step.

Originally published on Medium.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.