Saltar al contenido principal
Security

Passkeys y WebAuthn: Guía de Implementación en Producción para Autenticación FIDO2 sin Contraseña

10 min lectura
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
También disponible en: English, Italiano

El Modelo Mental que la Mayoría Entiende Mal

Todo el mundo explica WebAuthn como "claves SSH pero para la web." Es parecido pero engañoso. Las claves SSH residen en el directorio ~/.ssh y se gestionan manualmente. Los passkeys los gestiona la plataforma — el SO, el navegador, el password manager. La clave privada no es visible. No se exporta. No se le aplica chmod 600.

La ceremonia (sí, así le dice la spec verdad) funciona así:

  1. Registro: El servidor manda un challenge. El navegador le pide al authenticator que cree un par de claves. El authenticator guarda la clave privada y devuelve la clave pública + credential ID al servidor.
  2. Autenticación: El servidor manda un challenge + el credential ID. El navegador encuentra el authenticator correspondiente, que firma el challenge. El servidor verifica la firma.

Eso es todo. Sin secreto compartido. Sin hash de contraseña. Sin semilla TOTP. La clave privada nunca sale del hardware del authenticator.

Configurando el Servidor: SimpleWebAuthn

La librería @simplewebauthn/server es una elección práctica para evitar parsear CBOR manualmente. Acá está el setup de registro:

import {
 generateRegistrationOptions,
 verifyRegistrationResponse,
} from '@simplewebauthn/server';

const rpName = 'Mi App';
const rpID = 'miapp.com';
const origin = `https://${rpID}`;

async function handleRegistrationStart(user) {
 // Traemos credenciales existentes para poder excluirlas
 const existingCreds = await db.credentials.findByUserId(user.id);

 const options = await generateRegistrationOptions({
 rpName,
 rpID,
 userID: user.id,
 userName: user.email,
 // Evita re-registrar el mismo authenticator
 excludeCredentials: existingCreds.map(cred => ({
 id: cred.credentialID,
 type: 'public-key',
 transports: cred.transports,
 })),
 authenticatorSelection: {
 residentKey: 'required',
 userVerification: 'preferred',
 },
 // ES256 (P-256) primero, después RS256 como fallback
 supportedAlgorithmIDs: [-7, -257],
 });

 // Guardamos el challenge en la sesión — TENÉS que verificar esto después
 await sessionStore.set(user.id, {
 currentChallenge: options.challenge,
 });

 return options;
}

El residentKey: 'required' es crucial. Eso es lo que lo convierte en un "passkey" y no solo una credencial WebAuthn. Las resident keys (también llamadas discoverable credentials) se guardan en el authenticator mismo, lo que significa que el usuario no necesita dar un username primero — el authenticator puede listar las credenciales disponibles para el relying party.

El Lado del Cliente: Donde la Cosa se Pone Interesante

import { startRegistration } from '@simplewebauthn/browser';

async function register() {
 // Pedimos las opciones al servidor
 const optionsRes = await fetch('/api/auth/register/start', {
 method: 'POST',
 credentials: 'include',
 });
 const options = await optionsRes.json();

 let attestationResponse;
 try {
 attestationResponse = await startRegistration(options);
 } catch (err) {
 if (err.name === 'InvalidStateError') {
 // El authenticator ya está registrado
 showError('Este dispositivo ya está registrado.');
 return;
 }
 if (err.name === 'NotAllowedError') {
 // El usuario canceló o se pasó el tiempo
 showError('El registro fue cancelado.');
 return;
 }
 throw err;
 }

 // Mandamos la attestation al servidor para verificación
 const verifyRes = await fetch('/api/auth/register/finish', {
 method: 'POST',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify(attestationResponse),
 credentials: 'include',
 });

 const result = await verifyRes.json();
 if (result.verified) {
 showSuccess('¡Passkey registrado!');
 }
}

El manejo de errores aquí no es opcional. NotAllowedError salta cuando el usuario cierra el prompt del navegador, y si no lo atrapás con gracia, te va a llover un muro de reportes de bugs de gente que apretó Escape sin querer.

Verificación: El Paso Crítico

async function handleRegistrationFinish(user, body) {
 const session = await sessionStore.get(user.id);
 const expectedChallenge = session.currentChallenge;

 const verification = await verifyRegistrationResponse({
 response: body,
 expectedChallenge,
 expectedOrigin: origin,
 expectedRPID: rpID,
 requireUserVerification: false, // 'preferred' = no podemos requerirlo
 });

 const { verified, registrationInfo } = verification;

 if (verified && registrationInfo) {
 const {
 credentialPublicKey,
 credentialID,
 counter,
 credentialDeviceType,
 credentialBackedUp,
 } = registrationInfo;

 await db.credentials.create({
 credentialID: Buffer.from(credentialID).toString('base64url'),
 credentialPublicKey: Buffer.from(credentialPublicKey),
 counter,
 transports: body.response.transports || [],
 userId: user.id,
 deviceType: credentialDeviceType, // 'singleDevice' o 'multiDevice'
 backedUp: credentialBackedUp, // ¿sincronizado en la nube?
 createdAt: new Date(),
 });
 }

 return { verified };
}

Observar en credentialDeviceType y credentialBackedUp. Estos vienen del byte de flags en los authenticator data y te dicen algo importante: si este passkey está sincronizado entre dispositivos. Una credencial multiDevice + backedUp: true vive en iCloud Keychain o Google Password Manager. Una singleDevice + backedUp: false está atada a una llave de hardware. Puede que quieras diferentes niveles de confianza para cada una.

Flujo de Autenticación

import {
 generateAuthenticationOptions,
 verifyAuthenticationResponse,
} from '@simplewebauthn/server';

async function handleAuthStart() {
 const options = await generateAuthenticationOptions({
 rpID,
 userVerification: 'preferred',
 // allowCredentials vacío = que el authenticator muestre todos los passkeys disponibles
 allowCredentials: [],
 });

 // Guardamos el challenge — en prod utilizar un store server-side de vida corta
 await challengeStore.set(options.challenge, {
 createdAt: Date.now(),
 expiresAt: Date.now() + 60000, // 60 segundos
 });

 return options;
}

El array vacío de allowCredentials es la clave de la experiencia passkey. Cuando pasás un array vacío, el navegador muestra un modal con todos los passkeys que el usuario tiene para tu origen. Eligen uno, se autentican con biométricos, listo. Sin campo de username. Sin dropdown de "¿cuál cuenta?". El authenticator lo maneja.

Autenticación Cross-Device: El Transporte Hybrid

Acá la cosa se pone complica de verdad. Supongamos que un usuario registró su passkey en el iPhone y ahora está sentado en un escritorio Windows. La spec FIDO2 define un transporte "hybrid" (antes llamado caBLE — cloud-assisted Bluetooth Low Energy):

  1. El navegador del escritorio muestra un QR code
  2. El usuario lo escanea con el celu
  3. El celu y el escritorio establecen un túnel BLE
  4. El celu pide autenticación biométrica
  5. La assertion firmada viaja de vuelta por el túnel al escritorio

Vos no implementás esto. El navegador y el SO manejan la capa de transporte. Pero se necesita saber que existe porque:

  • Es lento. El handshake BLE agrega 3-8 segundos encima del flujo normal.
  • Es frágil. Bluetooth es Bluetooth. Los datos de analytics muestran que falla en aproximadamente el 12% de los primeros intentos.
  • Los usuarios no entienden por qué el celular les vibra cuando están tratando de loguearse en la notebook.

El enfoque recomendado: cuando un flujo cross-device es probable (no hay platform authenticator disponible), mostrar una UI explicativa breve antes de disparar la ceremonia WebAuthn. Algo como "Se va a conectar con tu celular por Bluetooth. Asegurate de tener el celu cerca y desbloqueado."

La Cuestión del Attestation

Durante el registro, el authenticator puede incluir un attestation statement — una prueba criptográfica del make y modelo del authenticator, firmada por el fabricante. La spec soporta varios formatos: packed, tpm, android-key, fido-u2f, apple, y none.

La recomendación práctica, basada en múltiples implementaciones en producción: no verificar attestation a menos que haya obligación legal.

La verificación de attestation significa:

  • Mantener un store de certificados raíz para cada vendor de authenticators
  • Manejar validación de cadena de certificados
  • Lidiar con el FIDO Metadata Service (MDS) que es una aventura en sí misma
  • Algunos navegadores muestran diálogos de permise es más intimidantes cuando se pide attestation

Para el 95% de las aplicaciones, attestation: 'none' es la opción correcta.

La Migración: Coexistencia Passwords + Passkeys

Las contraseñas no se pueden sacar de un saque. Acá va un enfoque por fases probado en producción:

Fase 1: Agregar registro de passkey en configuración de cuenta. Mantener el login con contraseña como primario. Medir adopción.

Fase 2: Después del login, sugerile a los usuarios de solo-contraseña que registren un passkey. Que sea un nudge suave, no un bloqueante.

Fase 3: Una vez que un usuario tenga passkey, hacelo el método de login por defecto. Mostrá "Usar contraseña en su lugar" como opción secundaria.

Fase 4: Para usuarios con passkeys, iniciar a requerir el passkey para operaciones sensibles (cambio de contraseña, cambio de email, métodos de pago).

// Middleware: escalar a passkey para rutas sensibles
async function requirePasskeyEscalation(req, res, next) {
 const user = req.user;
 const hasPasskey = await db.credentials.existsForUser(user.id);

 if (!hasPasskey) return next(); // sin passkey = fallback a sesión

 const lastPasskeyAuth = req.session.lastPasskeyVerification;
 const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;

 if (!lastPasskeyAuth || lastPasskeyAuth < fiveMinutesAgo) {
 return res.status(403).json({
 error: 'passkey_required',
 message: 'Verificá con tu passkey para continuar.',
 });
 }

 next();
}

Consideraciones de Producción

El problema del RP ID: Tu RP ID típicamente es tu dominio. Si se registran passkeys en app.misitio.com, no van a funcionar en misitio.com ni en api.misitio.com. Elegir tu RP ID con cuidado — es posible setearlo al dominio registrable (misitio.com) que va a funcionar para todos los subdominios, pero no es posible cambiarlo después sin invalidar todos los passkeys existentes.

Semántica del counter: La spec dice que los authenticators deberían incrementar un signature counter, y los relying parties deberían rechazar assertions donde el counter va para atrás (detección de authenticator clonado). En la práctica, los passkeys sincronizados a menudo tienen un counter de 0 que nunca incrementa. SimpleWebAuthn maneja esto bien, pero si se está haciendo tu propia implementación, no trates un counter en cero como sospechoso.

La opinión de Safari: Safari en macOS va a empujar agresivamente a los usuarios hacia iCloud Keychain incluso cuando tienen una security key de hardware enchufada. La única forma de evitar esto es conditional UI vía PublicKeyCredential.isConditionalMediationAvailable().

// Conditional UI: passkey en el dropdown de autofill
if (await PublicKeyCredential.isConditionalMediationAvailable()) {
 const options = await fetchAuthOptions();
 const assertion = await navigator.credentials.get({
 publicKey: options,
 mediation: 'conditional', // Acá está la magia
 });
 await verifyOnServer(assertion);
}

Métricas Después de 6 Meses

Después de hacer el rollout a ~40k usuarios:

  • 62% de usuarios activos registraron al menos un passkey
  • El tiempo promedio de autenticación bajó de 14 segundos (email + contraseña + 2FA) a 3 segundos
  • Los tickets de bloqueo de cuenta bajaron un 78%
  • Cero incidentes de credential stuffing (antes ~2/mes pegando en el WAF)

El impacto de negocio de un login más rápido es difícil de exagerar. Los datos de producción muestran un uptick medible en frecuencia de sesiones — la gente se loguea más cuando no se siente como un trámite.

Hacia Dónde Va Esto

La spec está evolucionando rápido. El draft de WebAuthn Level 3 incluye signal APIs para manejo del ciclo de vida de credenciales — cosas como decirle al authenticator "esta credencial fue revocada, por favor eliminala." Eso va a cerrar la brecha de UX más grande que queda, donde los usuarios acumulan passkeys viejos que no pueden limpiar.

Los passkeys no son perfectos. El flujo cross-device es tosco. La recuperación de cuenta es un problema sin resolver que todos barren abajo de la alfombra. El ecosistema de vendors está fragmentado de formas molestas. Pero el modelo de seguridad es fundamentalmente sólido, la UX es genuinamente mejor para la mayoría de los usuarios, y el momentum del ecosistema es real. Si seguís construyendo flujos de auth nuevos con contraseñas como factor primario, se está construyendo código legacy.

El punto de partida recomendado es @simplewebauthn/server, desplegando passkeys como segundo factor opcional, midiendo adopción, e iterando. La parte difícil no es la criptografía — es la UX de migración. Y ese es un problema mucho mejor que otro incidente de credential stuffing a las 3 AM.

passkeyswebauthnfido2passwordlessautenticaciónseguridadcriptografía

Herramientas mencionadas en este artículo

Auth0Probá Auth0
ClerkProbá Clerk
Divulgación: Algunos enlaces en este artículo son enlaces de afiliado. Si te registrás a través de ellos, puedo recibir una comisión sin costo adicional para vos. Solo recomiendo herramientas que uso y en las que confío personalmente.
Compartir
Seguime