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.