Biometric authentication (fingerprint, face, iris) gives users both security and convenience. The BiometricPrompt API provides a consistent, system-managed UI across Android versions and hardware.
BiometricManager: Check Availability
Before showing any biometric UI, check whether the device can authenticate:
class BiometricHelper(private val context: Context) {
fun canAuthenticate(): BiometricCapability {
val manager = BiometricManager.from(context)
return when (manager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)) {
BiometricManager.BIOMETRIC_SUCCESS -> BiometricCapability.AVAILABLE
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> BiometricCapability.NO_HARDWARE
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> BiometricCapability.HARDWARE_UNAVAILABLE
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricCapability.NOT_ENROLLED
else -> BiometricCapability.UNAVAILABLE
}
}
}
enum class BiometricCapability {
AVAILABLE,
NO_HARDWARE,
HARDWARE_UNAVAILABLE,
NOT_ENROLLED,
UNAVAILABLE
}
BiometricPrompt: Show the Prompt
class BiometricAuthManager(private val activity: FragmentActivity) {
private val executor = ContextCompat.getMainExecutor(activity)
fun authenticate(
onSuccess: (BiometricPrompt.AuthenticationResult) -> Unit,
onError: (Int, CharSequence) -> Unit,
onFailed: () -> Unit
) {
val biometricPrompt = BiometricPrompt(
activity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess(result)
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
onError(errorCode, errString)
}
override fun onAuthenticationFailed() {
onFailed() // Called when a recognized finger/face doesn't match
}
}
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Verify your identity")
.setSubtitle("Use your fingerprint or face to continue")
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL // fallback to PIN/pattern
)
.build()
biometricPrompt.authenticate(promptInfo)
}
}
Biometric-Gated Keystore Operations
The most secure pattern: require biometric authentication to use a Keystore key:
fun generateBiometricKey(alias: String): SecretKey {
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
keyGenerator.init(
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true) // ← requires biometric/PIN
.setUserAuthenticationParameters(
30, // timeout: 30 seconds
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
)
.build()
)
return keyGenerator.generateKey()
}
// Use the biometric-gated key:
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
biometricPrompt.authenticate(promptInfo, cryptoObject)
// In onAuthenticationSucceeded:
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val authenticatedCipher = result.cryptoObject?.cipher ?: return
val decryptedData = authenticatedCipher.doFinal(encryptedToken)
// Use decryptedData — you're sure the user just authenticated
}
UX Best Practices
Show biometric prompt at the right time:
// ✅ Show on sensitive actions, not on every resume
class PaymentFragment : Fragment() {
fun onPayNowClicked() {
biometricAuthManager.authenticate(
onSuccess = { processPayment() },
onError = { code, message -> showError(message) },
onFailed = {} // don't show error per-failure — system shows its own UI
)
}
}
Handle enrollment gracefully:
when (biometricHelper.canAuthenticate()) {
BiometricCapability.NOT_ENROLLED -> {
// Guide user to enroll
val enrollIntent = Intent(Settings.ACTION_BIOMETRIC_ENROLL).apply {
putExtra(Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED,
BiometricManager.Authenticators.BIOMETRIC_STRONG)
}
startActivity(enrollIntent)
}
BiometricCapability.AVAILABLE -> showBiometricPrompt()
else -> fallbackToPin()
}
Error codes to handle specially:
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
when (errorCode) {
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
// User cancelled — don't show an error dialog
}
BiometricPrompt.ERROR_LOCKOUT,
BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> {
showMessage("Too many attempts — use your PIN")
}
else -> showError(errString)
}
}
Key Takeaways
| Concept | Rule |
|---|---|
canAuthenticate() | Check before showing any biometric UI |
DEVICE_CREDENTIAL fallback | Always include — users without biometrics still need access |
CryptoObject | Link authentication to a specific crypto operation for maximum security |
ERROR_USER_CANCELED | Don't show error dialog — the user chose to cancel |
ERROR_LOCKOUT | Inform user to use PIN; system enforces the lockout |
setUserAuthenticationRequired(true) | Keystore key can only be used after biometric auth |