¿Cómo implementás autenticación biométrica (Face ID/huella) de forma correcta y segura?
Los prompts de Face ID y huella parecen un portón de sí/no, pero la implementación segura en realidad libera una clave respaldada por hardware — si te equivocás en esa distinción, la función de 'seguridad' se vuelve trivialmente evitable.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo implementás autenticación biométrica (Face ID/huella) de forma correcta y segura?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#La biometría libera una clave, no un booleano
El error número uno en autenticación biométrica es tratar a LocalAuthentication/BiometricPrompt como un if (elUsuarioEsQuienDice) con más glamour. El modelo mental correcto es otro: Face ID, Touch ID y las APIs de huella/rostro de Android existen para liberar el acceso a una clave criptográfica respaldada por hardware que vive en el Secure Enclave (iOS) o en el Trusted Execution Environment detrás del Android Keystore. El sensor biométrico nunca sale del dispositivo, la app nunca recibe datos crudos de huella o rostro y — esto es lo crítico — un resultado "exitoso" nunca debería, por sí solo, ser lo que desbloquea un recurso. Debería ser lo que permite usar una clave para una operación criptográfica puntual.
#Cómo lo conectan realmente las plataformas
En iOS creás un par de claves asimétricas (o un ítem de Keychain) con un SecAccessControl que exige biometría, y cualquier operación contra esa clave — firmar, descifrar — dispara automáticamente la hoja de Face ID/Touch ID del sistema. En Android generás una clave en el AndroidKeyStore con setUserAuthenticationRequired(true), envolvés un Cipher alrededor de ella y se lo pasás a BiometricPrompt como CryptoObject. En ambos casos el sistema operativo se niega a dejar que la clave haga nada hasta que la verificación biométrica pase — el código de tu app nunca evalúa "¿la huella fue correcta?", sólo llega a intentar usar una clave que está inerte hasta desbloquearse.
import LocalAuthenticationimport Securityfunc crearClaveProtegidaPorBiometria() throws {let access = SecAccessControlCreateWithFlags(kCFAllocatorDefault,kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,[.privateKeyUsage, .biometryCurrentSet], // se invalida si cambia la biometría inscriptanil)!let attributes: [String: Any] = [kSecClass as String: kSecClassKey,kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,kSecAttrKeySizeInBits as String: 256,kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,kSecPrivateKeyAttrs as String: [kSecAttrIsPermanent as String: true,kSecAttrApplicationTag as String: "com.example.app.biometric-signing-key",kSecAttrAccessControl as String: access,],]var error: Unmanaged<CFError>?guard SecKeyCreateRandomKey(attributes as CFDictionary, &error) != nil else {throw error!.takeRetainedValue()}}
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")keyGenerator.init(KeyGenParameterSpec.Builder("biometric_session_key",KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT).setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).setUserAuthenticationRequired(true).setInvalidatedByBiometricEnrollment(true).build())keyGenerator.generateKey()val cipher = Cipher.getInstance("AES/GCM/NoPadding")val secretKey = keyStore.getKey("biometric_session_key", null) as SecretKeycipher.init(Cipher.ENCRYPT_MODE, secretKey)val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {val cipherDesbloqueado = result.cryptoObject?.cipher ?: returnval tokenCifrado = cipherDesbloqueado.doFinal(sessionSecretBytes)// recién acá tenés algo utilizable — no hay ningún booleano que falsear}})prompt.authenticate(BiometricPrompt.PromptInfo.Builder().setTitle("Confirmá que sos vos").setSubtitle("Desbloqueá tu sesión guardada").setNegativeButtonText("Usar contraseña de la cuenta").build(),BiometricPrompt.CryptoObject(cipher))
#El antipatrón que casi todos entregan primero
La versión ingenua compila, pasa QA, y es trivialmente evitable en un dispositivo con jailbreak o root:
// ANTIPATRÓN — no lo publiques asílet context = LAContext()context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ inif success {self.isUnlocked = true // una bandera simple, sin respaldo de nada}}
El problema no es que Face ID se pueda engañar con una foto (en la práctica, no se puede) — es que en un dispositivo con jailbreak o root, herramientas como Frida u objection pueden interceptar el completion handler de evaluatePolicy o el callback de BiometricPrompt y forzar success = true incondicionalmente, sin ninguna huella real. Si isUnlocked = true es lo único que separa a un atacante de la pantalla protegida de tu app o del saldo de una billetera, ese hook es todo el ataque. Cuando la biometría en cambio protege una clave criptográfica real, el mismo hook no le da nada al atacante: el material de la clave sigue viviendo en el Secure Enclave/TEE y la app sigue sin poder producir una firma válida ni descifrar un payload válido sin ella.
#Invalidar claves cuando cambia la inscripción biométrica
Si alguien agrega una huella o un rostro nuevo al dispositivo — incluyendo un atacante con acceso físico y tiempo suficiente para inscribir el suyo —, una clave creada con .biometryCurrentSet (iOS) se invalida automáticamente, así que ya no se puede desbloquear en absoluto; el usuario tiene que reautenticarse con un factor más fuerte y reprovisionar la clave. El equivalente en Android es setInvalidatedByBiometricEnrollment(true) en el KeyGenParameterSpec. Usar .biometryAny/dejar esa bandera apagada hace que la clave sobreviva a nuevas inscripciones, lo cual a veces es lo que querés (dispositivos compartidos en familia), pero es el default equivocado para cualquier cosa que proteja dinero o datos personales.
#Combinar biometría local con confianza del servidor
Para cualquier funcionalidad que hable con un backend, el desbloqueo local por sí solo no es autenticación ante el servidor — es autenticación ante el dispositivo. El patrón que resiste una revisión: la clave protegida por biometría firma un nonce o un desafío de corta duración emitido por tu backend, y el servidor verifica esa firma contra una clave pública que registró en un login real anterior (contraseña + MFA). Eso convierte "Face ID tuvo éxito" en "la clave privada que sólo tiene este Secure Enclave produjo una firma válida sobre este desafío emitido por el servidor", algo que un booleano interceptado jamás puede falsificar porque el atacante sigue sin tener la clave privada.
#Fallbacks y casos borde que te van a preguntar
Diseñá siempre un camino de respaldo explícito — passcode del dispositivo, o el PIN propio de tu app — para dispositivos sin hardware biométrico, para usuarios que lo desactivaron, y para el bloqueo por demasiados intentos fallidos que imponen tanto iOS como Android (no intentes reimplementar el conteo de intentos vos mismo). Nunca hagas de la biometría la única puerta de entrada, y nunca degrades en silencio a "sin autenticación" cuando el hardware no está disponible — es una regresión común que se publica sin que nadie la note porque sólo aparece en dispositivos viejos o de gama baja que QA no probó.
🧠 Autenticación biométrica
0/3 · 0/3 answered1. ¿Por qué `if (biometricSuccess) { isUnlocked = true }` se considera un antipatrón?
2. ¿Contra qué protege `.biometryCurrentSet` (iOS) / `setInvalidatedByBiometricEnrollment(true)` (Android)?
3. En una funcionalidad conectada a un backend, ¿qué debería producir finalmente una verificación biométrica local exitosa?