La API de biometría — una historia de consolidación
Android tuvo múltiples APIs de biometría a lo largo de los años: FingerprintManager (API 23), BiometricPrompt original (API 28), y finalmente la versión de Jetpack que unifica todo. La recomendación actual es usar exclusivamente androidx.biometric — maneja todos los tipos de biometría (huella, face, iris), proporciona fallback a PIN/patrón/contraseña, y funciona en API 23+ con el mismo código.
Lo que hace BiometricPrompt de Jetpack por vos:
- Muestra el diálogo del sistema (no podés personalizarlo más allá del título y subtítulo — es intencional por seguridad)
- Maneja automáticamente huella, face unlock e iris según lo que el dispositivo soporte
- Ofrece fallback a PIN/patrón/contraseña si la biometría falla N veces
- Cancela el prompt automáticamente si la app va al background
Verificar disponibilidad antes de mostrar el prompt
No todos los dispositivos tienen biometría, y no todos los usuarios la tienen configurada. Verificar antes de intentar usarla evita errores en runtime:
// Los posibles estados de disponibilidad:
// BIOMETRIC_SUCCESS → listo para usar
// BIOMETRIC_ERROR_NO_HARDWARE → el dispositivo no tiene sensor biométrico
// BIOMETRIC_ERROR_HW_UNAVAILABLE → hardware presente pero no disponible ahora
// BIOMETRIC_ERROR_NONE_ENROLLED → hardware presente pero el usuario no registró biometría
// BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED → requiere actualización de seguridad
// BIOMETRIC_STATUS_UNKNOWN → no se puede determinar
fun verificarDisponibilidadBiometria(context: Context): BiometricDisponibilidad {
val manager = BiometricManager.from(context)
// Verificar si puede autenticar con biometría fuerte O con PIN/contraseña
return when (manager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)) {
BiometricManager.BIOMETRIC_SUCCESS ->
BiometricDisponibilidad.Disponible
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE ->
BiometricDisponibilidad.SinHardware
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE ->
BiometricDisponibilidad.HardwareNoDisponible
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
BiometricDisponibilidad.NoConfigurada // llevar al usuario a Configuración
else ->
BiometricDisponibilidad.Desconocido
}
}
sealed class BiometricDisponibilidad {
object Disponible : BiometricDisponibilidad()
object SinHardware : BiometricDisponibilidad()
object HardwareNoDisponible : BiometricDisponibilidad()
object NoConfigurada : BiometricDisponibilidad() // el usuario no registró biometría
object Desconocido : BiometricDisponibilidad()
}
Cuando el usuario no tiene biometría configuradaSi el estado es BIOMETRIC_ERROR_NONE_ENROLLED, podés llevar al usuario a configurarla. Desde Android 11 hay un Intent específico: Settings.ACTION_BIOMETRIC_ENROLL. En versiones anteriores: Settings.ACTION_SECURITY_SETTINGS.
Setup
// build.gradle (app)
dependencies {
implementation("androidx.biometric:biometric:1.2.0-alpha05")
// O la versión estable:
implementation("androidx.biometric:biometric-ktx:1.2.0-alpha05")
}
// AndroidManifest.xml — no requiere permisos especiales
// El permiso USE_BIOMETRIC se agrega automáticamente por la librería
Mostrar el BiometricPrompt
class LoginFragment : Fragment() {
// BiometricPrompt DEBE crearse con el Fragment o Activity actual
// como LifecycleOwner — esto garantiza que se cancela automáticamente
// cuando el componente va al background
private val biometricPrompt by lazy {
BiometricPrompt(
this, // Fragment o AppCompatActivity
ContextCompat.getMainExecutor(requireContext()),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
// Autenticación exitosa
// result.authenticationType indica si fue biometría o PIN
viewModel.onAutenticacionExitosa()
}
override fun onAuthenticationFailed() {
// El intento falló (huella incorrecta, cara no reconocida)
// pero el sistema sigue esperando — no cerrar el prompt
// El sistema maneja el límite de intentos automáticamente
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
// Error fatal — el prompt se cerró
when (errorCode) {
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
// El usuario canceló explícitamente
}
BiometricPrompt.ERROR_LOCKOUT,
BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> {
// Demasiados intentos fallidos — bloqueado temporalmente
viewModel.onBiometriaBlockeada()
}
BiometricPrompt.ERROR_NO_BIOMETRICS -> {
// No hay biometría configurada — redirigir a configuración
}
else -> {
// Otro error (hardware, timeout, etc.)
viewModel.onErrorBiometria(errString.toString())
}
}
}
}
)
}
// El PromptInfo describe lo que ve el usuario
private val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Verificar identidad")
.setSubtitle("Confirmá tu identidad para continuar")
.setDescription("Usá tu huella, rostro o PIN")
// Permitir tanto biometría fuerte como PIN/patrón/contraseña como fallback
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
// Si solo permitís biometría (sin PIN), podés agregar botón negativo:
// .setNegativeButtonText("Cancelar")
// (setNegativeButtonText y DEVICE_CREDENTIAL son mutuamente excluyentes)
.build()
fun mostrarBiometria() {
biometricPrompt.authenticate(promptInfo)
}
}
setNegativeButtonText y DEVICE_CREDENTIAL son mutuamente excluyentesSi permitís PIN/contraseña como fallback con DEVICE_CREDENTIAL, el sistema ya provee su propio botón de cancelar. Si usás setNegativeButtonText, tenés que manejar vos el caso de que el usuario quiera usar el PIN.
Manejar los resultados correctamente
// Los códigos de error más importantes y qué hacer con cada uno:
// ERROR_USER_CANCELED (código 10)
// El usuario tocó fuera del diálogo o presionó Back
// → No mostrar el prompt de nuevo automáticamente (sería molesto)
// → Mantener la pantalla bloqueada, esperar a que el usuario decida
// ERROR_NEGATIVE_BUTTON (código 13)
// El usuario tocó el botón negativo (si configuraste setNegativeButtonText)
// → Ofrecer método alternativo de autenticación
// ERROR_LOCKOUT (código 7)
// Demasiados intentos fallidos — bloqueado 30 segundos
// → Mostrar mensaje y esperar. No intentar de nuevo hasta que el sistema lo permita
// ERROR_LOCKOUT_PERMANENT (código 9)
// Demasiados lockouts — requiere PIN/patrón para desbloquear
// → Si usás DEVICE_CREDENTIAL, el sistema lo maneja automáticamente
// → Si no, redirigir al usuario a Settings
// ERROR_NO_DEVICE_CREDENTIAL (código 14)
// El dispositivo no tiene PIN/patrón/contraseña configurado
// → El usuario necesita configurar un método de bloqueo primero
// onAuthenticationFailed (sin código de error)
// Intento fallido pero el sistema sigue esperando más intentos
// → NO hacer nada especial, el sistema ya muestra el feedback al usuario
// → NO cerrar el prompt, NO mostrar tu propio mensaje de error
override fun onAuthenticationFailed() {
// Correcto: no hacer nada
// El sistema ya vibra y muestra "Huella no reconocida" o similar
}
Ciclo de vida — el problema del prompt duplicado
El caso más común de bug con biometría: el prompt aparece dos veces después de una rotación de pantalla, o aparece al volver al foreground aunque el usuario ya autenticó. La solución correcta es manejar el estado en el ViewModel:
// ViewModel — controlar si el prompt ya se mostró
class LoginViewModel : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState = _uiState.asStateFlow()
// Channel para eventos de un solo uso (mostrar el prompt)
private val _efectos = Channel<LoginEfecto>(Channel.BUFFERED)
val efectos = _efectos.receiveAsFlow()
fun iniciar() {
if (_uiState.value.autenticado) return // ya autenticó, no mostrar de nuevo
viewModelScope.launch {
_efectos.send(LoginEfecto.MostrarBiometria)
}
}
fun onAutenticacionExitosa() {
_uiState.update { it.copy(autenticado = true) }
viewModelScope.launch {
_efectos.send(LoginEfecto.NavegaAHome)
}
}
fun onBiometriaBlockeada() {
_uiState.update { it.copy(biometriaBlockeada = true) }
}
}
sealed class LoginEfecto {
object MostrarBiometria : LoginEfecto()
object NavegaAHome : LoginEfecto()
}
// Fragment — reaccionar a los efectos (solo una vez)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.efectos.collect { efecto ->
when (efecto) {
LoginEfecto.MostrarBiometria -> biometricPrompt.authenticate(promptInfo)
LoginEfecto.NavegaAHome -> navegarAHome()
}
}
}
}
}
// Disparar la autenticación al entrar a la pantalla
viewModel.iniciar()
}
Keystore + biometría — el nivel enterprise
Para apps bancarias o con datos muy sensibles, podés configurar una clave del Keystore que requiere autenticación biométrica reciente para usarse. Sin autenticación, la clave es inaccesible — aunque alguien tenga acceso root al dispositivo.
// 1. Generar la clave en el Keystore con requisito de autenticación
fun crearClaveConBiometria(alias: String) {
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)
.setKeySize(256)
// CRÍTICO: requiere autenticación biométrica para usar esta clave
.setUserAuthenticationRequired(true)
// Tiempo de validez de la autenticación en segundos
// 0 = requiere autenticación en cada operación de cifrado
// 30 = la autenticación es válida por 30 segundos
.setUserAuthenticationParameters(
30,
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
)
// Si el usuario agrega una nueva biometría, la clave se invalida
.setInvalidatedByBiometricEnrollment(true)
.build()
)
keyGenerator.generateKey()
}
// 2. Inicializar el Cipher ANTES de mostrar el prompt
fun crearCipherParaCifrado(alias: String): Cipher? {
return try {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val secretKey = (keyStore.getEntry(alias, null) as KeyStore.SecretKeyEntry).secretKey
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
cipher
} catch (e: KeyPermanentlyInvalidatedException) {
// El usuario agregó una nueva biometría — la clave fue invalidada
// Hay que regenerar la clave y pedirle al usuario que se re-autentique
null
}
}
// 3. Pasar el Cipher al BiometricPrompt
val cipher = crearCipherParaCifrado(KEY_ALIAS) ?: return
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
// Mostrar el prompt con el CryptoObject
biometricPrompt.authenticate(promptInfo, cryptoObject)
// 4. En onAuthenticationSucceeded, usar el Cipher autenticado
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val cipherAutenticado = result.cryptoObject?.cipher ?: return
// Ahora el Cipher está "desbloqueado" y puede usarse para cifrar/descifrar
val datosCifrados = cipherAutenticado.doFinal("datos sensibles".toByteArray())
// Guardar datosCifrados + cipher.iv en almacenamiento seguro
}
La diferencia entre autenticación simple y CryptoObjectSin CryptoObject, la autenticación biométrica solo te dice "el usuario se autenticó" — pero un atacante con acceso al proceso podría fabricar esa señal. Con CryptoObject, la autenticación está criptográficamente vinculada a la operación de cifrado: si la autenticación no ocurrió, el Cipher no puede operar. Es el nivel de seguridad que usan las apps bancarias.
Clases de autenticación — STRONG vs WEAK
// BIOMETRIC_STRONG (Clase 3)
// → Huella digital en la mayoría de los dispositivos
// → Face unlock con cámara dedicada (ej: iris scanner)
// → Puede usarse con CryptoObject para operaciones del Keystore
// → Requerido para apps financieras
// BIOMETRIC_WEAK (Clase 2)
// → Face unlock por cámara frontal estándar (menos seguro)
// → NO puede usarse con CryptoObject
// → Adecuado para conveniencia sin datos muy sensibles
// DEVICE_CREDENTIAL
// → PIN, patrón o contraseña del dispositivo
// → Puede combinarse con BIOMETRIC_STRONG como fallback
// Configuraciones comunes:
// Para conveniencia (login, desbloquear pantalla):
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
// Para datos sensibles (tokens, cifrado):
BiometricManager.Authenticators.BIOMETRIC_STRONG
// Para apps financieras/bancarias (máxima seguridad):
BiometricManager.Authenticators.BIOMETRIC_STRONG
// + CryptoObject con clave del Keystore
Casos edge importantes
KeyPermanentlyInvalidatedException
// Cuando el usuario agrega una nueva biometría, todas las claves con
// setInvalidatedByBiometricEnrollment(true) se invalidan permanentemente
// Hay que regenerarlas y pedirle al usuario que se re-autentique
fun descifrarConBiometria(alias: String): Boolean {
return try {
val cipher = crearCipherParaDescifrado(alias)
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
true
} catch (e: KeyPermanentlyInvalidatedException) {
// Clave invalidada — regenerar y limpiar datos cifrados con la clave vieja
eliminarClaveInvalidada(alias)
regenerarClave(alias)
viewModel.pedirReautenticacion("Tu biometría cambió. Por seguridad, volvé a iniciar sesión.")
false
}
}
Biometría en background — NO funciona
// BiometricPrompt SOLO puede mostrarse cuando la app está en foreground
// Si intentás mostrarlo desde un Service, WorkManager o BroadcastReceiver:
// IllegalStateException: "Not attached to an activity"
// El patrón correcto para apps que necesitan autenticación al volver al foreground:
class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
// Si la sesión expiró mientras la app estaba en background
if (viewModel.sesionExpirada()) {
// Mostrar pantalla de lock y el prompt
mostrarPantallaLock()
}
}
}
// Para apps que bloquean después de X minutos de inactividad,
// guardar el timestamp del último uso en el ViewModel y verificar en onResume
Emulador — biometría simulada
# Activar la huella virtual en el emulador:
# Android Studio → Emulator → ... → Virtual sensors → Fingerprint
# O desde la línea de comandos:
adb -e emu finger touch 1 # simula una huella correcta
adb -e emu finger touch 5 # simula una huella incorrecta
# Para face unlock en el emulador: no está disponible en todos los AVD
# Usar un AVD con API 29+ y Play Store para mejores resultados
Cuándo usar biometría — y cuándo no
- Sí usar: login de apps bancarias o financieras, confirmar transacciones de alto valor, desbloquear secciones sensibles de la app, proteger datos del Keystore.
- Sí usar (conveniencia): login rápido en apps que ya tienen cuenta, desbloquear después de inactividad, confirmar compras en apps de e-commerce.
- No usar como único factor: la biometría puede fallar (huella húmeda, cara con mascarilla). Siempre ofrecer un fallback (PIN o contraseña).
- No usar para datos altamente sensibles sin CryptoObject: la autenticación simple ("el usuario se autenticó") no tiene garantías criptográficas. Para cifrar tokens de sesión o datos médicos, usá el flujo con Keystore + CryptoObject.