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
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.
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.
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.
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_WEAKis acceptable, and you accept that no cryptographic proof is available. - On API 28 and 29, combining
BIOMETRIC_STRONG or DEVICE_CREDENTIALthrowsIllegalArgumentExceptionas soon as you callPromptInfo.Builder.build(), beforeauthenticate()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.
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.
7. Failure modes that show up after release
These are the ones I would put on a pre-launch checklist.
-
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. CatchKeyPermanentlyInvalidatedException, delete the alias, and force a password login to re-wrap the secret. -
onAuthenticationFailedmistaken 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. -
Backgrounding yields
ERROR_CANCELED. If the user switches apps mid-prompt, you get a cancel you did not cause. Re-prompting automatically ononResume()creates an annoying loop, so wait for a deliberate tap. -
API 28–29 combination crash.
BIOMETRIC_STRONG or DEVICE_CREDENTIALis rejected there. Test on real or emulated API 29 images, not only on the latest. -
Missing negative button. Omitting
setNegativeButtonTextwithoutDEVICE_CREDENTIALthrows atbuild(). Adding both throws too. -
Weak class with a
CryptoObject. ABIOMETRIC_WEAKauthenticator 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. -
Rotation and process death. Prompts built inside click handlers lose their callbacks. Build in
onCreate, route to theViewModel, and never hold sensitive state (including the decrypted passphrase) inSavedStateHandle. -
Long-lived secrets in memory. Zero the passphrase
ByteArrayas 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. - 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.
- 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.



