← Back to list

BiometricPrompt Done Right (Android)

Part 7 of our Android security series. Part 6 covered where to store sensitive data — this post covers a common mistake in gating access to…

Khizar Khan · 2026-08-18 06:13 · 0 claps · 2.6 min read
#android-development #mobile-security #cybersecurity #application-security #biometrics
Open on Medium ↗
Wiki topics: 📱 · Mobile Development 🔒 · Cybersecurity

BiometricPrompt Done Right (Android)

Part 7 of our Android security series. Part 6 covered where to store sensitive data — this post covers a common mistake in gating access to it: biometrics that check a box but don’t actually secure anything.

The mistake almost everyone makes first

Here’s biometric auth that looks secure but isn’t:

// ✗ Don't do this
biometricPrompt.authenticate(promptInfo)

override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
    // "They passed the fingerprint check, so… show the data"
    unlockAndShow(savedPassword)
}

The problem: this only confirms a fingerprint was scanned. It proves nothing about whether that data should actually be released, because savedPassword was sitting in plain reach the whole time. On a rooted device, or with the right tooling, this exact check has been bypassed by patching the app to skip straight to onAuthenticationSucceeded() — the biometric scan becomes theater.

The fix: tie the prompt to a CryptoObject

Real biometric protection means the biometric check is required to unlock a cryptographic key — not just to flip a boolean in your app’s UI logic. The Android Keystore key itself refuses to operate unless a fresh biometric check has just happened; your app code can’t fake that.

Step 1 — create a key that requires authentication (this builds on Part 5’s Keystore setup):

val spec = KeyGenParameterSpec.Builder(
    "biometric_gated_key",
    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
).apply {
    setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    setUserAuthenticationRequired(true)
    setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
}.build()

Step 2 — pass a CryptoObject wrapping that key into the prompt:

val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, iv))
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))

Step 3 — only use the cipher from inside the success callback:

override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
    val cipher = result.cryptoObject?.cipher ?: return
    val decrypted = cipher.doFinal(encryptedData)
    // Now, and only now, you have the real data
}

If the biometric check is bypassed or spoofed somehow, the cipher was never unlocked — doFinal() simply fails. The security lives in the Keystore's enforcement, not in your app's if statement.

Set the right authenticator strength

Android biometrics come in classes. Don’t default to the weakest one for anything sensitive:

promptInfo = BiometricPrompt.PromptInfo.Builder()
    .setTitle("Confirm it's you")
    .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
    .build()
  • **BIOMETRIC_STRONG** (Class 3) — required for anything gating a CryptoObject. This is your default for payments, saved credentials, sensitive records.
  • **BIOMETRIC_WEAK** (Class 2) — fine for low-stakes convenience, like "skip re-typing your PIN to reopen the app." Never pair this with a CryptoObject.
  • **DEVICE_CREDENTIAL** — allows PIN/pattern/password as a fallback. Reasonable to combine with BIOMETRIC_STRONG for account-level login; deliberately excluded when you want a purely biometric gate.

Check availability before you even show the prompt

Not every device has usable biometric hardware, and users can have it enrolled-but-broken (e.g., all fingerprints removed). Check first, and offer a real fallback instead of a dead-end error:

val biometricManager = BiometricManager.from(context)
when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
    BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo)
    BiometricManager.BIOMETRIC_NONE_ENROLLED -> promptUserToEnroll()
    else -> showAlternativeAuth()
}

Handle lockouts explicitly

override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
    when (errorCode) {
        BiometricPrompt.ERROR_LOCKOUT ->
            showMessage("Too many attempts. Try again shortly.")
        BiometricPrompt.ERROR_LOCKOUT_PERMANENT ->
            showPasswordOrPinFallback()
    }
}

ERROR_LOCKOUT_PERMANENT means biometrics are locked until the user unlocks with their device credential — don't leave them stuck with no way forward.

Quick checklist

  • [ ] Every sensitive biometric gate uses a CryptoObject, never a bare success callback
  • [ ] BIOMETRIC_STRONG required wherever a CryptoObject is involved
  • [ ] Availability checked with canAuthenticate() before showing the prompt
  • [ ] ERROR_LOCKOUT / ERROR_LOCKOUT_PERMANENT handled with a real fallback path
  • [ ] KeyPermanentlyInvalidatedException (from Part 5) handled if the user changes their screen lock or re-enrolls a fingerprint

The one-line takeaway

If your biometric check can be bypassed by simply reaching onAuthenticationSucceeded() through some other path, it isn't securing anything — real biometric protection means the sensitive operation itself refuses to run without a fresh, hardware-verified check.

Next up in the series: Credential Manager and modern sign-in — passkeys, passwords, and federated login in one unified flow.


메타데이터
post_id
2d7ea3ac4a01
slug
biometricprompt-done-right-android-2d7ea3ac4a01
url
https://medium.com/@khizarkhan8/biometricprompt-done-right-android-2d7ea3ac4a01
canonical_url
https://medium.com/@khizarkhan8/biometricprompt-done-right-android-2d7ea3ac4a01
author_url
https://medium.com/@khizarkhan8
status
ok
fetched_at
2026-08-21 01:45:42