1) Introducción detallada

Implementar biometría en Flutter no consiste explicitar “usar FaceID/TouchID” y listo. La biometría es un mecanismo de desbloqueo (un gesto de usuario) que habilita o autoriza el acceso a un secreto protegido por el sistema (Keychain/Secure Enclave en iOS, Keystore/StrongBox en Android). En otras palabras: la biometría, por sí sola, no es una contraseña ni un factor que tú puedas verificar; quien valida el rostro/huella es el SO y tú recibes una respuesta de “autenticado” dentro de un contexto de seguridad y políticas (tiempos de revalidación, invalidez si cambian biométricos, si hay passcode, etc.). En Flutter, el plugin más habitual para esto es local_auth, que expone una API común para iOS y Android. Sin embargo, lo realmente crítico no es el diálogo biométrico, sino qué proteges detrás y cómo: tokens de sesión, llaves de cifrado, claves privadas (firma), o simplemente un “gate” a una pantalla.

Un error recurrente es usar biometría como un simple booleano: “si autentica, muestro la app”. Eso es insuficiente si los secretos viven en almacenamiento sin protección fuerte o si el token se puede exfiltrar. La implementación robusta combina (1) autenticación local (FaceID/TouchID/biométricos Android) con (2) almacenamiento seguro de credenciales o llaves (Keychain/Keystore) y (3) políticas de acceso: reautenticación tras inactividad, al abrir la app, ante cambios de biometría, o al realizar acciones críticas. Además, debes diseñar para escenarios reales: usuario sin biometría configurada, fallos por bloqueo temporal, fallback a PIN, aplicación en background/foreground, y dispositivos comprometidos (root/jailbreak, depuración, hooking).

En este artículo voy directo a lo que importa: arquitectura, APIs, casos límite y hardening. Verás cómo implementar FaceID/TouchID (y equivalentes Android) en Flutter de forma coherente con el modelo de seguridad del SO, cómo atar el acceso a llaves guardadas, cómo manejar el ciclo de vida y cómo evitar falsas sensaciones de seguridad. El objetivo es que tu biometría no sea una “pantalla bonita”, sino una capa bien integrada en una estrategia de protección de datos y sesión.

2) Contexto histórico o teórico

La biometría moderna en móviles nace como respuesta a dos problemas: (1) fricción del usuario al introducir contraseñas/PINs repetidamente y (2) necesidad de elevar el nivel de seguridad sin sacrificar usabilidad. Apple populariza Touch ID (2013) y posteriormente Face ID (2017), con un enfoque claro: los datos biométricos no salen del dispositivo y se procesan dentro de un entorno seguro. En iOS, el núcleo es Secure Enclave y el framework LocalAuthentication, donde la app nunca recibe la huella/rostro, solo un resultado de evaluación de política (deviceOwnerAuthenticationWithBiometrics o deviceOwnerAuthentication). En Android, la evolución fue más fragmentada: de APIs específicas de huella a BiometricPrompt (AndroidX) para unificar biometría (huella/rostro/iris) y credenciales del dispositivo, apoyado por Keystore y, cuando existe, hardware-backed/StrongBox.

Teóricamente, la biometría es un factor “inherente”, pero con matices: no es secreta en el mismo sentido que una contraseña (dejas huellas, tu cara es pública), por lo que su rol práctico en móviles es actuar como “unlock” para un secreto almacenado de forma segura. En iOS, Keychain permite marcar ítems con kSecAccessControlBiometryCurrentSet o similares para invalidarlos si cambia el set biométrico. En Android, Keystore permite generar claves con requerimiento de autenticación (y invalidarlas ante nuevos biométricos, según configuración y versión). La consecuencia práctica: la implementación segura suele consistir en guardar un secreto (o clave) protegido por el sistema y exigir biometría cada vez que ese secreto se use o se exporte.

En Flutter, la abstracción añade otra capa: plugins que llaman a APIs nativas. Eso simplifica, pero también puede ocultar detalles importantes: diferencias en fallback, tiempos de validez, mensajes al usuario, restricciones de UI thread, y compatibilidad por versión. Por eso conviene entender el modelo subyacente y diseñar con políticas: ¿la biometría protege “abrir app” o protege “firmar una transacción”? ¿Qué pasa si el usuario añade una huella nueva? ¿Qué haces si el dispositivo está rooteado? ¿Cómo gestionas “biometría obligatoria” vs “conveniente”?

3) Sección técnica principal 1: Modelo de seguridad y amenazas (qué protege la biometría y qué no)

Primero define el objetivo de seguridad. En apps reales hay, al menos, tres escenarios:

  • Gate de UI: impedir que alguien con el teléfono desbloqueado vea datos dentro de la app (por ejemplo, banca o salud). Aquí la biometría actúa como “segundo paso” para acceder a pantallas sensibles.
  • Desbloqueo de secreto local: almacenar un token/clave cifrado y exigir biometría para descifrarlo o usarlo. Esto sube mucho el nivel porque aunque extraigan el almacenamiento, el secreto no es reutilizable fuera del dispositivo.
  • Autorización de acciones: requerir biometría para transferencias, cambios de perfil, exportación de datos, etc. El usuario puede estar “logueado”, pero la acción requiere reautenticación local.

Ahora, amenazas típicas y cómo abordarlas:

  • Robo del dispositivo desbloqueado: la biometría reduce exposición si la app se bloquea al ir a background o tras inactividad. Necesitas un “app lock” con timers y AppLifecycleState.
  • Exfiltración de tokens: si guardas tokens en SharedPreferences o almacenamiento no protegido, la biometría no sirve. Debes usar Keychain/Keystore vía flutter_secure_storage y, mejor, cifrado de dominio con claves hardware-backed cuando sea viable.
  • Root/Jailbreak + hooking: la biometría puede ser “bypasseada” a nivel de runtime si el atacante manipula tu proceso. Mitiga con detección de integridad, hardening, RASP, y, sobre todo, minimiza el valor de lo que el cliente puede revelar (tokens de corta vida, attestation, rotación).
  • Biometría cambiada: si el usuario añade una huella/rostro, tus secretos deberían invalidarse (según tu política). En iOS se puede forzar con “current set” en access control; en Android depende de flags y versión. A nivel Flutter, muchas apps no lo controlan y se quedan con un token reutilizable aunque haya cambiado el set biométrico.
  • Replay de sesión: aunque haya biometría, si el token es de larga vida y no hay binding al dispositivo, alguien con el token puede reutilizarlo en otro dispositivo. Solución: tokens de corta duración + refresh atado a claves del dispositivo + DPoP/MTLS si aplica.

La idea clave: local_auth es el “prompt”. La seguridad real está en cómo almacenas y usas secretos, en tus políticas de reautenticación y en reducir el valor de los tokens que puedan ser robados. Si implementas biometría como “booleano de acceso” pero mantienes un JWT persistente de semanas en texto plano, tu biometría es cosmética.

4) Sección técnica principal 2: Dependencias Flutter, configuración iOS/Android y permisos

En Flutter, la base para biometría es local_auth. Para almacenamiento seguro: flutter_secure_storage. Para una capa adicional (integridad): device_info_plus y, si decides, soluciones de attestation (nativas o backend). La receta mínima:

  • local_auth: mostrar prompt biométrico y obtener resultado.
  • flutter_secure_storage: guardar tokens/secretos en Keychain/Keystore.

pubspec.yaml (versiones orientativas; ajusta a tu proyecto):

dependencies:
  flutter:
    sdk: flutter
  local_auth: ^2.2.0
  flutter_secure_storage: ^9.2.2

iOS (FaceID/TouchID):

  • En ios/Runner/Info.plist, añade el motivo de uso de Face ID. Sin esto, FaceID falla en runtime.
<key>NSFaceIDUsageDescription</key>
<string>Usamos Face ID para proteger el acceso a tu cuenta y autorizar acciones sensibles.</string>

En iOS, no “pides permiso” como cámara; el sistema gestiona el prompt. Pero el texto anterior es obligatorio si la app puede invocar FaceID.

Android (BiometricPrompt):

  • Asegura que el minSdk sea compatible con tu estrategia (BiometricPrompt funciona bien desde 23 con AndroidX; el plugin abstrae, pero revisa release notes).
  • En AndroidManifest.xml se suelen declarar permisos biométricos. En versiones modernas: USE_BIOMETRIC.
<uses-permission android:name="android.permission.USE_BIOMETRIC" />

También puede aparecer USE_FINGERPRINT por compatibilidad antigua, pero en proyectos actuales se prioriza USE_BIOMETRIC.

Puntos de fricción reales:

  • Emuladores: en iOS Simulator puedes simular FaceID/TouchID, en Android Emulator también (depende de imagen). No uses esto como prueba de seguridad; solo de flujo.
  • Mensajes del sistema: iOS controla gran parte del copy; Android permite más personalización con títulos/subtítulos en BiometricPrompt (expuesto parcialmente por el plugin).
  • Fallback: iOS puede permitir passcode del dispositivo si usas la política correcta. Android puede permitir credenciales del dispositivo (PIN/patrón) según configuración.

Una implementación sólida debe decidir explícitamente: ¿solo biometría o biometría + credencial del dispositivo? Esa decisión afecta UX y seguridad, y debe ser consistente con el nivel de riesgo de la acción protegida.

5) Sección técnica principal 3: Flujo de autenticación biométrica con local_auth (estados, errores y UX antifallo)

La API de local_auth te da tres piezas: (1) si hay soporte, (2) qué biometrías están disponibles, (3) disparar authenticate(). El error típico es llamar authenticate sin comprobar disponibilidad o sin manejar excepciones. En producción, tienes que tratar los estados como una máquina: no disponible, disponible pero no configurado, bloqueado temporalmente, bloqueado permanentemente, usuario canceló, app pasó a background durante el prompt, etc.

Servicio de biometría (Dart) con manejo de errores:

import 'package:local_auth/local_auth.dart';
import 'package:local_auth/error_codes.dart' as auth_errors;

class BiometricService {
  final LocalAuthentication _auth = LocalAuthentication();

  Future<bool> canUseBiometrics() async {
    final canCheck = await _auth.canCheckBiometrics;
    final isSupported = await _auth.isDeviceSupported();
    return canCheck && isSupported;
  }

  Future<List<BiometricType>> availableBiometrics() async {
    try {
      return await _auth.getAvailableBiometrics();
    } catch (_) {
      return const [];
    }
  }

  Future<bool> authenticate({
    required String reason,
    bool biometricOnly = true,
  }) async {
    try {
      return await _auth.authenticate(
        localizedReason: reason,
        options: AuthenticationOptions(
          biometricOnly: biometricOnly,
          stickyAuth: true,
          useErrorDialogs: true,
          sensitiveTransaction: true,
        ),
      );
    } catch (e) {
      // Mapea excepciones a tu dominio
      rethrow;
    }
  }

  Future<String> mapAuthException(Object e) async {
    if (e is PlatformException) {
      switch (e.code) {
        case auth_errors.notAvailable:
          return 'Biometría no disponible en este dispositivo.';
        case auth_errors.notEnrolled:
          return 'No hay biometría configurada. Configura FaceID/TouchID o huella.';
        case auth_errors.lockedOut:
          return 'Biometría bloqueada temporalmente. Usa el método alternativo.';
        case auth_errors.permanentlyLockedOut:
          return 'Biometría bloqueada. Debes desbloquear con PIN del dispositivo.';
        default:
          return 'Error biométrico: ${e.code}';
      }
    }
    return 'Error biométrico desconocido.';
  }
}

Decisiones importantes en options:

  • biometricOnly: si es true, no permite fallback a credenciales del dispositivo en plataformas donde existe. Esto es más estricto pero puede empeorar UX (usuarios con FaceID fallando en condiciones de luz, etc.).
  • stickyAuth: útil cuando la app se va a background durante el prompt (por ejemplo, notificaciones). Evita que el flujo se rompa.
  • useErrorDialogs: deja que el SO muestre diálogos estándar (mejor consistencia).
  • sensitiveTransaction: en Android informa al sistema de que la acción es sensible; influye en UI/flags internos.

UX antifallo: define un “fallback” propio: PIN interno de la app, password del usuario o re-login. Si el dispositivo no tiene biometría configurada, no puedes bloquear al usuario sin alternativa. Lo serio es: (1) ofreces biometría como opción (default ON si el riesgo lo permite) y (2) si se activa, aseguras una ruta de recuperación clara.

Control de concurrencia: evita múltiples prompts simultáneos. Usa un lock (por ejemplo, un Completer global o mutex) para impedir dobles llamadas desde UI.

6) Sección técnica principal 4: Protegiendo secretos correctamente (Keychain/Keystore) y binding con biometría

El salto de “biometría cosmética” a “biometría segura” ocurre cuando el secreto (refresh token, clave de cifrado, clave privada) no es accesible sin una autenticación local. Aquí hay dos enfoques:

  • Enfoque A (común en Flutter): Guardar el token en flutter_secure_storage y usar biometría como gate de UI antes de leerlo/usar sesiones. Es razonable, pero no siempre obliga a biometría para extraer el secreto (depende de configuración interna del plugin y plataforma).
  • Enfoque B (más fuerte): En nativo, crear ítems/llaves con políticas de acceso que requieran biometría/pin para su uso. En iOS con SecAccessControl y “biometryCurrentSet”; en Android con Keystore y claves que requieren autenticación. En Flutter puro, esto suele requerir un plugin especializado o código platform-channel propio.

Si buscas “profundidad extrema” y seguridad real, el enfoque B es el objetivo. Aun así, en muchos productos Flutter, el enfoque A es el punto de partida. Lo importante es entender sus límites.

Implementación práctica híbrida (recomendada en Flutter):

  1. Guarda un refresh token en flutter_secure_storage (Keychain/Keystore).
  2. Guarda además una marca de “biometría habilitada”.
  3. Antes de usar el refresh token (p.ej., al abrir app o en acciones críticas), exige local_auth.authenticate.
  4. En backend, usa refresh tokens de corta vida y rotación; si el dispositivo se compromete, reduces el impacto.

Ejemplo de almacenamiento seguro:

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class SecureTokenStore {
  static const _kRefreshToken = 'refresh_token';
  static const _kBiometryEnabled = 'biometry_enabled';

  final FlutterSecureStorage _storage;

  SecureTokenStore(this._storage);

  Future<void> setBiometryEnabled(bool enabled) async {
    await _storage.write(key: _kBiometryEnabled, value: enabled ? '1' : '0');
  }

  Future<bool> isBiometryEnabled() async {
    return (await _storage.read(key: _kBiometryEnabled)) == '1';
  }

  Future<void> saveRefreshToken(String token) async {
    await _storage.write(key: _kRefreshToken, value: token);
  }

  Future<String?> readRefreshToken() async {
    return _storage.read(key: _kRefreshToken);
  }

  Future<void> clear() async {
    await _storage.delete(key: _kRefreshToken);
    await _storage.delete(key: _kBiometryEnabled);
  }
}

Hardening adicional: invalidación por cambio de biometría. En iOS, la forma fuerte es usar Keychain con biometryCurrentSet. Si el usuario añade un nuevo rostro/huella, el ítem se vuelve inaccesible y fuerzas re-login. En Flutter, esto no viene “gratis”. Si tu amenaza incluye este escenario (por ejemplo, cuenta bancaria), considera un plugin o canal nativo para guardar el refresh token con acceso control estricto. Conceptualmente:

  • iOS: SecAccessControlCreateWithFlags(..., kSecAccessControlBiometryCurrentSet, ...)
  • Android: Keystore key con setUserAuthenticationRequired(true) y parámetros de invalidación (dependen de API).

Política recomendada:

  • Para “abrir app”: biometría puede ser gate + token en secure storage.
  • Para “firmar/autorizar”: usa claves de firma en Secure Enclave/Keystore (no exportables) y que el backend verifique firmas (challenge-response). Ahí la biometría sí protege criptográficamente la acción.

7) Sección técnica principal 5: Arquitectura de app-lock, ciclo de vida, reautenticación y acciones sensibles

Una app segura no pide biometría “porque sí”; la pide cuando cambia el riesgo. Eso se traduce en reglas:

  • Bloquear al pasar a background.
  • Reautenticar al volver si el tiempo en background supera un umbral (p.ej. 15–60s según sensibilidad).
  • Reautenticar antes de acciones sensibles aunque la app esté “desbloqueada”.
  • Evitar loops: si el usuario cancela, no volver a pedir automáticamente 10 veces.

Implementa un AppLockController con estado centralizado (Riverpod/Bloc/Provider), no disperso en pantallas. Debes separar:

  • Estado: locked/unlocked, lastUnlockAt, failedAttempts.
  • Política: timeout, qué rutas requieren lock, si biometría es obligatoria.
  • Acciones: lock(), unlockWithBiometrics(), unlockWithPin().

Ejemplo simplificado usando un controlador con WidgetsBindingObserver:

import 'dart:async';
import 'package:flutter/widgets.dart';

class AppLockController with WidgetsBindingObserver {
  final BiometricService biometrics;
  final SecureTokenStore store;

  bool _locked = true;
  DateTime? _lastBackgroundAt;
  DateTime? _lastUnlockAt;

  final Duration backgroundTimeout;

  AppLockController({
    required this.biometrics,
    required this.store,
    this.backgroundTimeout = const Duration(seconds: 30),
  });

  bool get isLocked => _locked;

  void start() {
    WidgetsBinding.instance.addObserver(this);
  }

  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) {
      _lastBackgroundAt = DateTime.now();
    }

    if (state == AppLifecycleState.resumed) {
      final bgAt = _lastBackgroundAt;
      if (bgAt != null) {
        final delta = DateTime.now().difference(bgAt);
        if (delta >= backgroundTimeout) {
          lock();
        }
      }
    }
  }

  void lock() {
    _locked = true;
  }

  Future<bool> unlockIfNeeded() async {
    if (!_locked) return true;

    final enabled = await store.isBiometryEnabled();
    if (!enabled) {
      return false; // obliga a PIN/login según tu UX
    }

    if (!await biometrics.canUseBiometrics()) {
      return false;
    }

    final ok = await biometrics.authenticate(
      reason: 'Confirma tu identidad para acceder a la aplicación',
      biometricOnly: false,
    );

    if (ok) {
      _locked = false;
      _lastUnlockAt = DateTime.now();
    }
    return ok;
  }

  Future<T> guardSensitiveAction<T>({
    required String reason,
    required Future<T> Function() action,
  }) async {
    // Siempre reautentica para acciones sensibles
    final ok = await biometrics.authenticate(
      reason: reason,
      biometricOnly: false,
    );
    if (!ok) {
      throw StateError('Acción cancelada o no autorizada');
    }
    return action();
  }
}

Notas de arquitectura:

  • No bases la seguridad en “navegar a una pantalla”. El lock debe estar en un “shell” global (p.ej., un widget que muestra overlay de bloqueo).
  • Cuando el lock está activo, evita que el contenido sensible sea visible en el app switcher. En iOS/Android puedes añadir un overlay opaco al pausar (esto requiere nativo o paquetes específicos).
  • Las acciones sensibles deben reautenticarse aunque la app esté desbloqueada (política tipo PSD2/SCA en banca, por ejemplo).

Gestión de errores y “cooldown”: si hay muchos fallos, el SO puede bloquear biometría. Tu app debe detectar esto y ofrecer un fallback sin entrar en bucle. Además, registra telemetría (sin datos sensibles) para ver tasas de fallo por dispositivo y ajustar UX.

8) Ejemplos de código detallados (flujo completo: activar biometría, login, unlock y acción sensible)

Un flujo típico:

  1. Usuario hace login con credenciales.
  2. La app ofrece activar biometría.
  3. Si activa, se pide autenticación biométrica para confirmar intención y se guarda bandera + token en secure storage.
  4. En siguientes aperturas, si hay bandera, se pide biometría para desbloquear y usar refresh token.

Activación de biometría:

Future<void> enableBiometricsFlow({
  required BiometricService biometrics,
  required SecureTokenStore store,
  required String refreshToken,
}) async {
  final usable = await biometrics.canUseBiometrics();
  if (!usable) {
    throw StateError('No se puede usar biometría en este dispositivo');
  }

  final ok = await biometrics.authenticate(
    reason: 'Activa Face ID/Touch ID para proteger tu cuenta en este dispositivo',
    biometricOnly: false,
  );

  if (!ok) {
    throw StateError('Activación cancelada');
  }

  await store.saveRefreshToken(refreshToken);
  await store.setBiometryEnabled(true);
}

Unlock al abrir app (en un widget raíz):

import 'package:flutter/material.dart';

class LockGate extends StatefulWidget {
  final AppLockController lock;
  final Widget child;

  const LockGate({super.key, required this.lock, required this.child});

  @override
  State<LockGate> createState() => _LockGateState();
}

class _LockGateState extends State<LockGate> {
  bool _unlocking = false;

  @override
  void initState() {
    super.initState();
    widget.lock.start();
    WidgetsBinding.instance.addPostFrameCallback((_) => _tryUnlock());
  }

  @override
  void dispose() {
    widget.lock.dispose();
    super.dispose();
  }

  Future<void> _tryUnlock() async {
    if (_unlocking) return;
    setState(() => _unlocking = true);
    try {
      await widget.lock.unlockIfNeeded();
    } finally {
      if (mounted) setState(() => _unlocking = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    final locked = widget.lock.isLocked;

    return Stack(
      children: [
        widget.child,
        if (locked)
          Positioned.fill(
            child: Material(
              color: Colors.black,
              child: Center(
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    const Text('Aplicación bloqueada', style: TextStyle(color: Colors.white)),
                    const SizedBox(height: 12),
                    FilledButton(
                      onPressed: _unlocking ? null : _tryUnlock,
                      child: Text(_unlocking ? 'Comprobando…' : 'Desbloquear'),
                    ),
                    const SizedBox(height: 8),
                    TextButton(
                      onPressed: () {
                        // Aquí implementas fallback: PIN/login
                      },
                      child: const Text('Usar otro método'),
                    )
                  ],
                ),
              ),
            ),
          ),
      ],
    );
  }
}

Acción sensible con reautenticación:

Future<void> transferMoney(AppLockController lock) async {
  await lock.guardSensitiveAction(
    reason: 'Autoriza la transferencia con Face ID/Touch ID',
    action: () async {
      // Llamada real a API
      await Future.delayed(const Duration(milliseconds: 300));
    },
  );
}

Esto separa claramente: desbloqueo general (app-lock) y autorización de operación (step-up auth). Es un patrón sólido y auditable.

9) Comparativa de pros y contras

Enfoque Pros Contras Cuándo usar
Solo local_auth como gate de pantalla Implementación rápida; UX buena; cross-platform simple No protege secretos si están mal almacenados; bypass posible con hooking en dispositivo comprometido; riesgo de “falsa seguridad” Apps con riesgo bajo-medio o como primera capa junto a backend seguro
local_auth + flutter_secure_storage (tokens en Keychain/Keystore) Mejor protección del token; reduce exfiltración casual; fácil de mantener Puede no forzar biometría criptográficamente para el uso del secreto; invalidación por cambio biométrico no garantizada sin ajustes nativos Apps con datos sensibles moderados; buena relación esfuerzo/seguridad
Llaves no exportables (Secure Enclave/Keystore) + challenge-response Máxima seguridad: el secreto no sale; la acción se autoriza con firma; fuerte contra robo de tokens Mayor complejidad; requiere nativo o plugins específicos; backend debe soportar verificación y registro de claves Banca, salud, firma de operaciones, escenarios de alto riesgo
Permitir fallback a credencial del dispositivo Menos bloqueos; mejor accesibilidad; reduce soporte Menor “pureza” biométrica; si el PIN del dispositivo es débil, baja el nivel efectivo Protección de app-lock general; cuando priorizas disponibilidad
Biometría “solo” (sin fallback) Más estricto; evita downgrade a PIN Riesgo de lockout de usuarios; peor UX en fallos; soporte más caro Acciones críticas específicas, no como único método de acceso

10) Conclusión

FaceID/TouchID en Flutter se implementa técnicamente con local_auth, pero la biometría segura no se “activa” con un diálogo: se diseña. Si tu objetivo es proteger datos, necesitas políticas de reautenticación, bloqueo por ciclo de vida, gestión de errores y un fallback claro. Si tu objetivo es proteger secretos, debes asegurarte de que esos secretos residan en Keychain/Keystore y, en escenarios de alto riesgo, que estén ligados a llaves no exportables con requerimiento de autenticación para su uso.

La ruta pragmática para la mayoría de productos es: app-lock + secure storage + step-up auth. La ruta fuerte para alto riesgo es: clave hardware-backed + challenge-response + tokens cortos y rotación. En ambos casos, evita la falsa sensación de seguridad: la biometría no compensa tokens mal gestionados ni un backend permisivo. Diseña el sistema como un conjunto: cliente endurecido, secretos con lifecycle corto, almacenamiento seguro y autorización explícita para acciones sensibles. Eso es lo que convierte una integración de FaceID/TouchID en un control de seguridad real y defendible.