Biometric authentication on Android in 2026: BiometricPrompt, the Keystore, and locking a local-first app
A practical guide to androidx.biometric on Android — BiometricPrompt, CryptoObject-backed keys, device credential fallback, and the mistakes that let a fingerprint check protect nothing.
A fingerprint prompt that just gates a screen is decoration. If the underlying data is sitting in a plain Room database the whole time, anyone with adb and a rooted device — or a backup extraction tool — reads it without ever touching the sensor. Real biometric protection on Android means the prompt unlocks a cryptographic key, and that key is what actually stands between an attacker and the data. This is the part most BiometricPrompt tutorials skip, and it’s the only part that matters.
I added this to Granyn to lock the budgeting data on-device, and the same pattern is what protects a medication log or a subscription list in any local-first app. Here’s the version that’s actually load-bearing.
Two different things called “biometric auth”
androidx.biometric supports two authentication modes, and they protect completely different things:
- Authentication-only.
BiometricPrompt.authenticate(PromptInfo)with noCryptoObjectjust asks “did the enrolled biometric match?” and calls back yes or no. It’s a UI gate. Nothing is encrypted, nothing is decrypted — a rooted device or acontent providerbug bypasses it entirely, because the data never depended on the prompt succeeding. - Crypto-backed authentication.
BiometricPrompt.authenticate(PromptInfo, CryptoObject)ties the prompt to aCipherbuilt from an Android Keystore key that’s flaggedsetUserAuthenticationRequired(true). The OS itself refuses to let that key do anything — encrypt, decrypt, sign — until a matching biometric or device credential authentication just succeeded. The key becomes physically unusable without the prompt, not just conventionally gated.
If the goal is “keep the budgeting numbers unreadable without unlocking,” only the second one does that. The first one is fine for something lower-stakes, like re-confirming before a destructive action in a screen that isn’t handling sensitive data at rest.
Building a Keystore key that requires authentication
private const val KEY_ALIAS = "granyn_db_key"
fun getOrCreateKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
)
val spec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
.build()
keyGenerator.init(spec)
return keyGenerator.generateKey()
}
setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG) is the important line: a duration of 0 means the key is usable for exactly one operation per authentication, not for a rolling time window. That’s deliberately stricter than the older setUserAuthenticationValidityDurationSeconds, which let a key stay unlocked for N seconds after any successful check — convenient, and also the reason a lot of “biometric-protected” apps quietly protected nothing the second time a background service touched the key. Require BIOMETRIC_STRONG specifically; BIOMETRIC_WEAK covers things like unlocking with your face at an angle a photo can sometimes fool, which is not a bar you want guarding financial data.
Wiring the prompt to the cipher
suspend fun authenticateAndDecrypt(
activity: FragmentActivity,
ciphertext: ByteArray,
iv: ByteArray,
): ByteArray = suspendCancellableCoroutine { cont ->
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(128, iv))
}
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val decrypted = result.cryptoObject?.cipher?.doFinal(ciphertext)
if (decrypted != null) cont.resume(decrypted) else cont.cancel()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
cont.cancel()
}
}
val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock Granyn")
.setSubtitle("Confirm it's you to view your budget")
.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
.build()
prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
Two things here trip people up. First, setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) — combining strong biometric with the device’s PIN/pattern/password fallback — is mutually exclusive with setNegativeButtonText(). Call both and the prompt throws at build time. The device credential option already gives users an out when the sensor fails or nothing’s enrolled, so a manual negative button is redundant with it, not a companion to it.
Second, the CryptoObject you get back in onAuthenticationSucceeded isn’t a courtesy copy — it’s the same Cipher instance, now unlocked, that you must call doFinal() on immediately. Rebuilding a fresh Cipher after the callback fires defeats the entire point: that fresh cipher was never blessed by the authentication check, so trying to use it throws UserNotAuthenticatedException, or worse, if you built it without the auth requirement, it silently works and quietly reopens the hole you were closing.
Checking availability before you ever show the prompt
val biometricManager = BiometricManager.from(context)
when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) {
BiometricManager.BIOMETRIC_SUCCESS -> { /* proceed */ }
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
// No fingerprint/face AND no PIN/pattern/password set. Route to
// Settings.ACTION_BIOMETRIC_ENROLL, don't just call authenticate()
// and let it fail silently.
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
// Fall back to an app-level PIN you manage yourself, or skip the
// lock feature entirely on this device rather than blocking access.
}
else -> { /* handle remaining error codes */ }
}
canAuthenticate() is not optional boilerplate — it’s the difference between a clear “set up a screen lock to enable this” prompt and a confusing failure the first time a user with no enrolled credential taps the toggle. Test this path deliberately: wipe the emulator’s enrolled fingerprints (Settings → Security → Fingerprint, or on the emulator’s extended controls, the Fingerprint panel) and confirm the app degrades instead of crashing.
What actually needs the lock
Not everything benefits from this. A crypto-backed key adds real friction — one authentication per decrypt, one Keystore round trip per read — so gating an entire database read path with it turns “open the app” into a noticeably slower operation. What I settled on for Granyn: encrypt only the fields that matter if the phone is lost — account balances and transaction notes — and leave category names and UI preferences in plain Room columns. The lock protects what’s actually sensitive; everything else stays fast.
The habit worth keeping from all of this: before shipping any “protect with your fingerprint” feature, ask what happens if the prompt is skipped entirely — root, a debugger, a content-provider bypass. If the answer is “nothing, the data’s still readable,” the prompt was never doing the job it looked like it was doing.
// Related reading
More from the journal
Health Connect on Android in 2026: reading and writing data without giving up local-first
A practical guide to Android's Health Connect API in 2026 — permissions, background reads, and why local-first apps should treat it as optional, not truth.
The Android In-App Review API: asking for a rating without being annoying
A practical guide to Google's In-App Review API — how it actually works, where to trigger it, and why the usual 'rate us' popup is quietly hurting your Play Store rating.
Android app shortcuts and Quick Settings Tiles: logging water without opening the app
A practical guide to Android's dynamic ShortcutManager API and TileService — how to let a one-tap action skip the app entirely, with the pitfalls that trip most implementations.