Cómo implementar autenticación passwordless con biometría y passkeys en tu aplicación web a medida
Las contraseñas llevan décadas siendo el punto flojo. Da igual cuánto endurezcas la política: tarde o temprano alguien la reutiliza, alguien la apunta en un post-it, alguien cae en un phishing. El Data Breach Investigations Report 2024 de Verizon lo cuantifica sin medias tintas: el 80 % de las brechas en aplicaciones web nacen de credenciales comprometidas. Todo ese mapa de ataque desaparece el día que retiras la contraseña del flujo.
La tecnología para hacerlo ya no está en beta. Los passkeys, respaldados por la FIDO Alliance y adoptados por Apple, Google y Microsoft desde 2022, cambian usuario/contraseña por criptografía de clave pública anclada a la biometría del dispositivo. Lo relevante si construyes software a medida: WebAuthn ya vive en todos los navegadores modernos, con cobertura por encima del 95 % según Can I Use.
A continuación recorremos la arquitectura, los flujos de registro y login, las decisiones de diseño en backend y frontend, y las trampas que aparecen al implementar autenticación passwordless con biometría y passkeys en una aplicación web a medida real.
Qué son los passkeys y por qué superan a las contraseñas
Un passkey es, en su núcleo, un par de claves asimétricas generadas según el estándar FIDO2. La privada nunca sale del dispositivo: vive en el enclave seguro del hardware (Secure Enclave en Apple, StrongBox en Android, TPM en Windows). La pública se registra en tu servidor y se queda ahí, inerte hasta que alguien quiere autenticarse.
¿Cómo se autentica el usuario? El servidor lanza un desafío (challenge) aleatorio. El dispositivo lo firma con la clave privada tras verificar al usuario por biometría o PIN local. Tu servidor valida esa firma contra la pública. Nunca viaja un secreto compartido. Aunque alguien intercepte el tráfico, no hay nada reutilizable.
Ventajas técnicas frente a contraseñas y OTP
- Resistencia a phishing: el passkey queda atado criptográficamente al dominio de origen (RP ID). Una página clon no activa el passkey registrado. El navegador se niega.
- Sin secretos compartidos que robar: no existe tabla de hashes que volcar. Un atacante que se lleve tu base de credenciales se lleva claves públicas, papel mojado sin la privada del dispositivo.
- Menos fricción: un toque en el sensor y dentro. Ni contraseñas que recordar, ni OTP que copiar a contrarreloj, ni SMS que tarda.
- Sincronización multidispositivo: los synced passkeys se replican entre dispositivos del mismo ecosistema vía llavero de iCloud, Google Password Manager o Windows Hello, cifrados extremo a extremo.
La pila de estándares: FIDO2, WebAuthn y CTAP2
Antes de teclear nada, conviene tener el mapa mental claro. Tres siglas que se mezclan continuamente:
- FIDO2 es el paraguas del proyecto de la FIDO Alliance.
- WebAuthn es la API JavaScript que el navegador expone para que tu app hable con autenticadores. Recomendación W3C desde marzo de 2019, Level 2 aprobado en 2021, Level 3 en borrador.
- CTAP2 (Client to Authenticator Protocol) define cómo se comunica el navegador con el autenticador, externo (llave USB, NFC) o de plataforma (sensor biométrico integrado).
Cuando usas passkeys con biometría integrada, tu app habla WebAuthn con el navegador y el navegador habla CTAP2 por dentro con el Secure Enclave. Tú solo tocas WebAuthn.
Arquitectura de una implementación passwordless en una app web a medida
Componentes del lado del servidor (Relying Party)
El servidor hace de Relying Party (RP). Sus tres trabajos:
- Generar desafíos: valores aleatorios de al menos 16 bytes, salidos de un CSPRNG, con vida útil corta. Por debajo de 5 minutos.
- Almacenar credenciales públicas: para cada registro guardas el
credentialId, la clave pública en formato COSE, el contador de firmas (signCount) y los metadatos del autenticador. - Validar aserciones: comprobar la firma del challenge, verificar origen y RP ID, y asegurarte de que el contador de firmas es estrictamente mayor que el almacenado. Esto último es lo que te protege contra clonación.
A nivel de esquema, una tabla mínima en base de datos se parece a esto:
credentials
├── id (UUID)
├── user_id (FK)
├── credential_id (bytes, único, indexado)
├── public_key_cose (bytes)
├── sign_count (integer)
├── transports (array: usb, nfc, ble, internal, hybrid)
├── created_at (timestamp)
└── last_used_at (timestamp)
Componentes del lado del cliente
En frontend la cosa se reduce, casi sorprendentemente, a dos llamadas sobre navigator.credentials:
navigator.credentials.create()para el registro (ceremony de registro).navigator.credentials.get()para el login (ceremony de autenticación).
Ambas reciben un PublicKeyCredentialCreationOptions o PublicKeyCredentialRequestOptions que tu servidor prepara y el frontend deserializa antes de pasárselo al navegador.
Flujo de comunicación completo
El registro se desarrolla así:
- El usuario pulsa "registrar passkey" en la interfaz.
- El frontend lanza
POST /webauthn/register/begin. - El backend genera el challenge, define las opciones de creación (algoritmos permitidos, tipo de autenticador, política de verificación) y devuelve el
PublicKeyCredentialCreationOptions. - El frontend invoca
navigator.credentials.create(options). - El navegador activa el autenticador de plataforma. El usuario verifica con huella o rostro.
- El autenticador genera el par de claves, firma el challenge y devuelve un
AuthenticatorAttestationResponse. - El frontend reenvía la respuesta con
POST /webauthn/register/finish. - El backend valida la attestation, extrae la clave pública y la guarda.
El login va de forma análoga: cambias create por get y attestation por assertion. Misma coreografía, distinta ceremonia.
Decisiones de diseño que afectan a producción
Algoritmos criptográficos
WebAuthn te deja declarar qué algoritmos acepta el RP a través de pubKeyCredParams. Dos cubren el 99 % del mercado:
- ES256 (ECDSA sobre P-256 con SHA-256, COSE algorithm identifier -7): lo soporta prácticamente cualquier autenticador. Primera opción siempre.
- RS256 (RSASSA-PKCS1-v1_5 con SHA-256, COSE algorithm identifier -257): por compatibilidad con autenticadores viejos basados en Windows Hello con TPM 1.2.
Incluir los dos en el array no abre ningún boquete y maximiza la compatibilidad.
Attestation: ¿conviene solicitarla?
El parámetro attestation admite none, indirect o direct. Sobre el papel suena bien: verificar modelo y fabricante del autenticador. A la práctica, introduce mucha complejidad operativa, porque acabas manteniendo una base de datos de certificados raíz (FIDO Metadata Service) y lidiando con formatos heterogéneos: packed, TPM, Android Key, Apple.
Para la mayoría de apps web a medida, attestation: "none" es lo pragmático. Solo tiene sentido pedir attestation real cuando hay un requisito regulatorio detrás (banca, sanidad) y necesitas garantizar un nivel concreto de certificación FIDO.
Política de verificación de usuario
El parámetro userVerification decide si el autenticador tiene que verificar al usuario (biometría/PIN) o basta con presencia física:
"required": siempre se pide biometría. Razonable para operaciones sensibles."preferred": la pide si está disponible, pero deja pasar sin ella."discouraged": presencia y nada más. Útil para llaves en flujos 2FA donde ya hay otro factor.
Para una autenticación passwordless pura, userVerification: "required". La biometría sustituye a la contraseña, así que necesitas garantías de que el autenticador ha comprobado al usuario.
Resident keys y passkeys descubribles
¿Quieres que el usuario inicie sesión sin teclear su nombre de usuario? Necesitas credenciales descubribles (discoverable credentials, antes resident keys). Se activan con residentKey: "required" y requireResidentKey: true en el registro.
Con credenciales descubribles el login cambia: el servidor manda el challenge sin allowCredentials, y el autenticador muestra al usuario la lista de passkeys disponibles para ese dominio. El usuario elige uno y se autentica. Aquí es donde la experiencia se siente verdaderamente sin contraseña.
Integración práctica en el backend
Reinventar WebAuthn desde cero es mala idea. Las bibliotecas maduras hacen el trabajo pesado:
- Python:
py_webauthn, mantenida activamente, cobertura Level 2 completa. - Node.js:
@simplewebauthn/server, la más extendida en Node, API limpia. - Java:
java-webauthn-serverde Yubico, robusta, usada en producción por organizaciones grandes. - Go:
go-webauthn, ligera y bien documentada. - .NET:
Fido2NetLib, compatible con .NET 6+.
Todas abstraen la serialización CBOR/COSE, la validación de firmas y la verificación de origen. Justo las tres áreas donde se cometen más errores tirando del hilo a mano. Salvo motivo muy concreto, apóyate en una de ellas.
Gestión de sesiones tras la autenticación
Validada la aserción WebAuthn, el servidor emite una sesión o un token. Lo demás es autenticación clásica. En un backend monolítico, sesiones en servidor (Redis o base de datos) te dan revocación inmediata. En arquitecturas distribuidas, un JWT de vida corta (5-15 minutos) con refresh token rotativo y revocable funciona bien.
Pero ojo: WebAuthn solo cubre el login inicial. Lo que pase después sigue las reglas de siempre. Cookies HttpOnly, Secure, SameSite=Strict, y rotación del identificador de sesión tras autenticar. Si descuidas esa parte, la criptografía de los passkeys no te salva.
Manejo de escenarios complejos
Recuperación de cuenta sin contraseña de respaldo
Si el usuario pierde todos sus dispositivos, no puede autenticarse. Punto. Estrategias razonables:
- Registrar varios passkeys desde el inicio: al menos dos dispositivos, o un passkey de plataforma más una llave USB de respaldo.
- Código de recuperación de un solo uso: lo generas en el registro, el usuario lo guarda offline (impreso, gestor de contraseñas), y queda hasheado y cifrado en tu servidor como semilla de última instancia.
- Verificación de identidad alternativa: en entornos empresariales, un proceso manual con el administrador que emita un enlace de re-registro temporal.
Lo que no debes hacer, bajo ningún concepto, es añadir un fallback de contraseña. Tira por la borda todo el modelo: el atacante apuntará directo a ese flujo más débil.
Autenticación cross-device (hybrid transport)
Caso típico: el usuario quiere entrar en un portátil usando el passkey que vive en su móvil. CTAP2 define para esto el transporte hybrid (antes caBLE). El navegador del portátil enseña un QR, el usuario lo escanea con el móvil, se levanta un túnel BLE/Internet y el móvil firma el desafío.
¿Cuánto código escribes tú? Cero. Basta con incluir "hybrid" en los transportes permitidos. El resto lo orquesta el navegador junto con el sistema operativo.
Migración progresiva desde contraseñas
Si ya tienes una app en marcha con usuarios y contraseñas, la migración se articula en tres fases:
- Ofrecimiento: justo después de un login con contraseña, le presentas al usuario la opción de registrar un passkey. Banner no intrusivo, copy directo: "Activa el acceso con huella para no volver a escribir tu contraseña".
- Incentivo: marcas el passkey como método preferido, reduces la caducidad de contraseñas y dejas que la presión vaya empujando.
- Desactivación: cuando el usuario tiene al menos dos passkeys registrados, ofrécele eliminar la contraseña. Voluntario, sin imponerlo.
Este escalado gradual evita dejar tirados a los usuarios con dispositivos antiguos y te permite medir tasas de adopción en frío en cada paso.
Errores frecuentes en implementaciones reales
Los tropiezos que casi nunca fallan al revisar integraciones WebAuthn ajenas:
- RP ID incorrecto: el RP ID tiene que ser un dominio válido que cuadre con el efectivo de la página. Si tu app vive en
app.ejemplo.com, puedes usarejemplo.comcomo RP ID (dominio padre), pero nuncaotro-dominio.com. Cuando esto baila, error silencioso y frustrante de depurar. - Challenge reutilizado o predecible: cada ceremony genera un challenge único. Guárdalo en la sesión y bórralo tras la validación, da igual si tuvo éxito o no.
- No validar el origen: la respuesta del autenticador trae
clientDataJSONcon el campoorigin. El servidor tiene que comprobar que coincide con el origen esperado, incluido esquema y puerto. Sin atajos. - Ignorar el signCount: el contador de firmas es tu única defensa contra clonación del autenticador. Si recibes un valor menor o igual al almacenado, rechaza la autenticación y alerta al usuario.
- Serialización incorrecta de ArrayBuffer: WebAuthn maneja
ArrayBufferyUint8Array, y el transporte entre frontend y backend casi siempre va en Base64URL. El matiz es que un encoding mal puesto corrompe credenciales silenciosamente: el registro funciona, el login falla, y no entiendes por qué.
Hacia una web sin contraseñas: lo que viene después de la primera integración
Con la autenticación passwordless rodando en producción, el siguiente movimiento natural es reutilizar el mismo flujo para autorizar operaciones sensibles. Confirmar una transferencia. Aprobar un cambio de permisos. Firmar un documento. WebAuthn permite lanzar una nueva ceremony en cualquier momento de la sesión, pidiendo al usuario que confirme con biometría antes de ejecutar. Adiós a los OTP por SMS y una superficie de ataque mucho más estrecha en lo crítico.
Los passkeys ya no son una feature experimental. Son el estándar sobre el que la industria ha terminado convergiendo tras años intentando soluciones intermedias (federación, magic links, OTP). Implementarlos bien pide entender el protocolo, decidir con criterio sobre attestation, verificación y recuperación, y probar en dispositivos reales antes de desplegar.
Si tu app sigue dependiendo de contraseñas y te planteas dar el salto, hablemos de cómo integrar passkeys en tu proyecto con un equipo que ya ha recorrido ese camino.