{"schemaVersion":1,"id":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation","title":"Passkeys y WebAuthn: Guía de Implementación en Producción para Autenticación FIDO2 sin Contraseña","description":"Un recorrido sin filtro por la implementación de passkeys FIDO2 con WebAuthn, credenciales residentes, autenticación cross-device, ceremonias de attestation, y todos los edge cases que te van a arruinar el finde.","language":"es","datePublished":"2025-12-29","dateModified":null,"contentFormat":"text/markdown","content":"Security\n\n# Passkeys y WebAuthn: Guía de Implementación en Producción para Autenticación FIDO2 sin Contraseña\n\n2025-12-2910 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation>)\n\n## El Modelo Mental que la Mayoría Entiende Mal\n\nTodo 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 `.\n\nLa ceremonia (sí, así le dice la spec verdad) funciona así:\n\n1. **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.\n2. **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.\n\nEso es todo. Sin secreto compartido. Sin hash de contraseña. Sin semilla TOTP. La clave privada nunca sale del hardware del authenticator.\n\n## Configurando el Servidor: SimpleWebAuthn\n\nLa librería [` @simplewebauthn/server `](<https://simplewebauthn.dev/>) es una elección práctica para evitar parsear CBOR manualmente. Acá está el setup de registro:\n\n```javascript\nimport {\n generateRegistrationOptions,\n verifyRegistrationResponse,\n} from '@simplewebauthn/server';\n\nconst rpName = 'Mi App';\nconst rpID = 'miapp.com';\nconst origin = `https://${rpID}`;\n\nasync function handleRegistrationStart(user) {\n // Traemos credenciales existentes para poder excluirlas\n const existingCreds = await db.credentials.findByUserId(user.id);\n\n const options = await generateRegistrationOptions({\n rpName,\n rpID,\n userID: user.id,\n userName: user.email,\n // Evita re-registrar el mismo authenticator\n excludeCredentials: existingCreds.map(cred => ({\n id: cred.credentialID,\n type: 'public-key',\n transports: cred.transports,\n })),\n authenticatorSelection: {\n residentKey: 'required',\n userVerification: 'preferred',\n },\n // ES256 (P-256) primero, después RS256 como fallback\n supportedAlgorithmIDs: [-7, -257],\n });\n\n // Guardamos el challenge en la sesión, TENÉS que verificar esto después\n await sessionStore.set(user.id, {\n currentChallenge: options.challenge,\n });\n\n return options;\n}\n```\n\nEl ` 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.\n\n## El Lado del Cliente: Donde la Cosa se Pone Interesante\n\n```javascript\nimport { startRegistration } from '@simplewebauthn/browser';\n\nasync function register() {\n // Pedimos las opciones al servidor\n const optionsRes = await fetch('/api/auth/register/start', {\n method: 'POST',\n credentials: 'include',\n });\n const options = await optionsRes.json();\n\n let attestationResponse;\n try {\n attestationResponse = await startRegistration(options);\n } catch (err) {\n if (err.name === 'InvalidStateError') {\n // El authenticator ya está registrado\n showError('Este dispositivo ya está registrado.');\n return;\n }\n if (err.name === 'NotAllowedError') {\n // El usuario canceló o se pasó el tiempo\n showError('El registro fue cancelado.');\n return;\n }\n throw err;\n }\n\n // Mandamos la attestation al servidor para verificación\n const verifyRes = await fetch('/api/auth/register/finish', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(attestationResponse),\n credentials: 'include',\n });\n\n const result = await verifyRes.json();\n if (result.verified) {\n showSuccess('¡Passkey registrado!');\n }\n}\n```\n\nEl 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.\n\n## Verificación: El Paso Crítico\n\n```javascript\nasync function handleRegistrationFinish(user, body) {\n const session = await sessionStore.get(user.id);\n const expectedChallenge = session.currentChallenge;\n\n const verification = await verifyRegistrationResponse({\n response: body,\n expectedChallenge,\n expectedOrigin: origin,\n expectedRPID: rpID,\n requireUserVerification: false, // 'preferred' = no podemos requerirlo\n });\n\n const { verified, registrationInfo } = verification;\n\n if (verified && registrationInfo) {\n const {\n credentialPublicKey,\n credentialID,\n counter,\n credentialDeviceType,\n credentialBackedUp,\n } = registrationInfo;\n\n await db.credentials.create({\n credentialID: Buffer.from(credentialID).toString('base64url'),\n credentialPublicKey: Buffer.from(credentialPublicKey),\n counter,\n transports: body.response.transports || [],\n userId: user.id,\n deviceType: credentialDeviceType, // 'singleDevice' o 'multiDevice'\n backedUp: credentialBackedUp, // ¿sincronizado en la nube?\n createdAt: new Date(),\n });\n }\n\n return { verified };\n}\n```\n\nObservar 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.\n\n## Flujo de Autenticación\n\n```javascript\nimport {\n generateAuthenticationOptions,\n verifyAuthenticationResponse,\n} from '@simplewebauthn/server';\n\nasync function handleAuthStart() {\n const options = await generateAuthenticationOptions({\n rpID,\n userVerification: 'preferred',\n // allowCredentials vacío = que el authenticator muestre todos los passkeys disponibles\n allowCredentials: [],\n });\n\n // Guardamos el challenge, en prod utilizar un store server-side de vida corta\n await challengeStore.set(options.challenge, {\n createdAt: Date.now(),\n expiresAt: Date.now() + 60000, // 60 segundos\n });\n\n return options;\n}\n```\n\nEl 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.\n\n## Autenticación Cross-Device: El Transporte Hybrid\n\nAcá 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):\n\n1. El navegador del escritorio muestra un QR code\n2. El usuario lo escanea con el celu\n3. El celu y el escritorio establecen un túnel BLE\n4. El celu pide autenticación biométrica\n5. La assertion firmada viaja de vuelta por el túnel al escritorio\n\nVos no implementás esto. El navegador y el SO manejan la capa de transporte. Pero se necesita saber que existe porque:\n\n- Es **lento**. El handshake BLE agrega 3-8 segundos encima del flujo normal.\n- Es **frágil**. Bluetooth es Bluetooth. Los datos de analytics muestran que falla en aproximadamente el 12% de los primeros intentos.\n- Los usuarios **no entienden** por qué el celular les vibra cuando están tratando de loguearse en la notebook.\n\nEl 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.\"\n\n## La Cuestión del Attestation\n\nDurante 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 `.\n\nLa recomendación práctica, basada en múltiples implementaciones en producción: **no verificar attestation a menos que haya obligación legal.**\n\nLa verificación de attestation significa:\n\n- Mantener un store de certificados raíz para cada vendor de authenticators\n- Manejar validación de cadena de certificados\n- Lidiar con el FIDO Metadata Service (MDS) que es una aventura en sí misma\n- Algunos navegadores muestran diálogos de permise es más intimidantes cuando se pide attestation\n\nPara el 95% de las aplicaciones, ` attestation: 'none' ` es la opción correcta.\n\n## La Migración: Coexistencia Passwords + Passkeys\n\nLas contraseñas no se pueden sacar de un saque. Acá va un enfoque por fases probado en producción:\n\n**Fase 1**: Agregar registro de passkey en configuración de cuenta. Mantener el login con contraseña como primario. Medir adopción.\n\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.\n\n**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.\n\n**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).\n\n```javascript\n// Middleware: escalar a passkey para rutas sensibles\nasync function requirePasskeyEscalation(req, res, next) {\n const user = req.user;\n const hasPasskey = await db.credentials.existsForUser(user.id);\n\n if (!hasPasskey) return next(); // sin passkey = fallback a sesión\n\n const lastPasskeyAuth = req.session.lastPasskeyVerification;\n const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;\n\n if (!lastPasskeyAuth || lastPasskeyAuth < fiveMinutesAgo) {\n return res.status(403).json({\n error: 'passkey_required',\n message: 'Verificá con tu passkey para continuar.',\n });\n }\n\n next();\n}\n```\n\n## Consideraciones de Producción\n\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.\n\n**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.\n\n**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() `.\n\n```javascript\n// Conditional UI: passkey en el dropdown de autofill\nif (await PublicKeyCredential.isConditionalMediationAvailable()) {\n const options = await fetchAuthOptions();\n const assertion = await navigator.credentials.get({\n publicKey: options,\n mediation: 'conditional', // Acá está la magia\n });\n await verifyOnServer(assertion);\n}\n```\n\n## Métricas Después de 6 Meses\n\nDespués de hacer el rollout a ~40k usuarios:\n\n- 62% de usuarios activos registraron al menos un passkey\n- El tiempo promedio de autenticación bajó de 14 segundos (email + contraseña + 2FA) a 3 segundos\n- Los tickets de bloqueo de cuenta bajaron un 78%\n- Cero incidentes de credential stuffing (antes ~2/mes pegando en el WAF)\n\nEl 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.\n\n## Hacia Dónde Va Esto\n\nLa 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.\n\nLos 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.\n\nEl 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.\n\n## Preguntas frecuentes\n\n### ¿Pueden los passkeys reemplazar completamente a las contraseñas hoy?\n\nPara la mayoría de las apps de consumo, sí. La cobertura en iOS 16+, Android 9+ y navegadores modernos es suficiente. Enterprise es más complicado por las políticas de dispositivos administrados e integraciones LDAP legacy.\n\n### ¿Qué pasa cuando un usuario pierde todos sus dispositivos?\n\nEste es el problema difícil. Necesitás un flujo de recuperación de cuenta, típicamente magic link por email o SMS OTP como fallback. Los passkeys sincronizados vía iCloud Keychain o Google Password Manager mitigan la pérdida de un solo dispositivo.\n\n### ¿Vale la pena verificar attestation en producción?\n\nPara la mayoría de las apps, no. Attestation te dice el modelo del authenticator pero agrega complejidad y preocupaciones de privacidad. Solo forzalo si se está en una industria regulada que exige hardware específico (como banca con FIPS 140-2).\n\n## Tips\n\n1. **Tip:** Siempre iniciar con ` authenticatorAttachment: 'platform' ` y hacé fallback a ` 'cross-platform' `, los usuarios prefieren el biométrico del celu antes que enchufar una llave de hardware.\n2. **Tip:** Guardá el ` credentialID ` como base64url, no como bytes crudos. Cada driver de base de datos maneja los blobs binarios distinto y resultará problemático en la migración.\n3. **Tip:** Utilizar ` userVerification: 'preferred' ` en vez de ` 'required' ` en producción, algunos authenticators viejos fallan silenciosamente con ` 'required' ` y tus usuarios ven una pantalla en blanco.\n4. **Tip:** Probar los flujos cross-device en hardware real. El transporte Hybrid (caBLE) entre Android y macOS no se comporta ni ahí como sugiere el emulador de Chrome DevTools.\n\n- passkeys\n- webauthn\n- fido2\n- passwordless\n- autenticación\n- seguridad\n- criptografía\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)","headings":[{"level":1,"text":"Passkeys y WebAuthn: Guía de Implementación en Producción para Autenticación FIDO2 sin Contraseña","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#title"},{"level":2,"text":"El Modelo Mental que la Mayoría Entiende Mal","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#el-modelo-mental-que-la-mayora-entiende-mal"},{"level":2,"text":"Configurando el Servidor: SimpleWebAuthn","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#configurando-el-servidor-simplewebauthn"},{"level":2,"text":"El Lado del Cliente: Donde la Cosa se Pone Interesante","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#el-lado-del-cliente-donde-la-cosa-se-pone-interesante"},{"level":2,"text":"Verificación: El Paso Crítico","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#verificacin-el-paso-crtico"},{"level":2,"text":"Flujo de Autenticación","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#flujo-de-autenticacin"},{"level":2,"text":"Autenticación Cross-Device: El Transporte Hybrid","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#autenticacin-cross-device-el-transporte-hybrid"},{"level":2,"text":"La Cuestión del Attestation","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#la-cuestin-del-attestation"},{"level":2,"text":"La Migración: Coexistencia Passwords + Passkeys","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#la-migracin-coexistencia-passwords-passkeys"},{"level":2,"text":"Consideraciones de Producción","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#consideraciones-de-produccin"},{"level":2,"text":"Métricas Después de 6 Meses","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#mtricas-despus-de-6-meses"},{"level":2,"text":"Hacia Dónde Va Esto","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#hacia-dnde-va-esto"},{"level":2,"text":"Preguntas frecuentes","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#questions"},{"level":3,"text":"¿Pueden los passkeys reemplazar completamente a las contraseñas hoy?","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#question-1"},{"level":3,"text":"¿Qué pasa cuando un usuario pierde todos sus dispositivos?","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#question-2"},{"level":3,"text":"¿Vale la pena verificar attestation en producción?","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#article-tips"}],"sections":[{"heading":"Passkeys y WebAuthn: Guía de Implementación en Producción para Autenticación FIDO2 sin Contraseña","level":1,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation","content":"Security"},{"heading":"Passkeys y WebAuthn: Guía de Implementación en Producción para Autenticación FIDO2 sin Contraseña","level":1,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#title","content":"2025-12-2910 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation>)"},{"heading":"El Modelo Mental que la Mayoría Entiende Mal","level":2,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#el-modelo-mental-que-la-mayora-entiende-mal","content":"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 `.\n\nLa ceremonia (sí, así le dice la spec verdad) funciona así:\n\n1. **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.\n2. **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.\n\nEso es todo. Sin secreto compartido. Sin hash de contraseña. Sin semilla TOTP. La clave privada nunca sale del hardware del authenticator."},{"heading":"Configurando el Servidor: SimpleWebAuthn","level":2,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#configurando-el-servidor-simplewebauthn","content":"La librería [` @simplewebauthn/server `](<https://simplewebauthn.dev/>) es una elección práctica para evitar parsear CBOR manualmente. Acá está el setup de registro:\n\n```javascript\nimport {\n generateRegistrationOptions,\n verifyRegistrationResponse,\n} from '@simplewebauthn/server';\n\nconst rpName = 'Mi App';\nconst rpID = 'miapp.com';\nconst origin = `https://${rpID}`;\n\nasync function handleRegistrationStart(user) {\n // Traemos credenciales existentes para poder excluirlas\n const existingCreds = await db.credentials.findByUserId(user.id);\n\n const options = await generateRegistrationOptions({\n rpName,\n rpID,\n userID: user.id,\n userName: user.email,\n // Evita re-registrar el mismo authenticator\n excludeCredentials: existingCreds.map(cred => ({\n id: cred.credentialID,\n type: 'public-key',\n transports: cred.transports,\n })),\n authenticatorSelection: {\n residentKey: 'required',\n userVerification: 'preferred',\n },\n // ES256 (P-256) primero, después RS256 como fallback\n supportedAlgorithmIDs: [-7, -257],\n });\n\n // Guardamos el challenge en la sesión, TENÉS que verificar esto después\n await sessionStore.set(user.id, {\n currentChallenge: options.challenge,\n });\n\n return options;\n}\n```\n\nEl ` 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."},{"heading":"El Lado del Cliente: Donde la Cosa se Pone Interesante","level":2,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#el-lado-del-cliente-donde-la-cosa-se-pone-interesante","content":"```javascript\nimport { startRegistration } from '@simplewebauthn/browser';\n\nasync function register() {\n // Pedimos las opciones al servidor\n const optionsRes = await fetch('/api/auth/register/start', {\n method: 'POST',\n credentials: 'include',\n });\n const options = await optionsRes.json();\n\n let attestationResponse;\n try {\n attestationResponse = await startRegistration(options);\n } catch (err) {\n if (err.name === 'InvalidStateError') {\n // El authenticator ya está registrado\n showError('Este dispositivo ya está registrado.');\n return;\n }\n if (err.name === 'NotAllowedError') {\n // El usuario canceló o se pasó el tiempo\n showError('El registro fue cancelado.');\n return;\n }\n throw err;\n }\n\n // Mandamos la attestation al servidor para verificación\n const verifyRes = await fetch('/api/auth/register/finish', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(attestationResponse),\n credentials: 'include',\n });\n\n const result = await verifyRes.json();\n if (result.verified) {\n showSuccess('¡Passkey registrado!');\n }\n}\n```\n\nEl 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."},{"heading":"Verificación: El Paso Crítico","level":2,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#verificacin-el-paso-crtico","content":"```javascript\nasync function handleRegistrationFinish(user, body) {\n const session = await sessionStore.get(user.id);\n const expectedChallenge = session.currentChallenge;\n\n const verification = await verifyRegistrationResponse({\n response: body,\n expectedChallenge,\n expectedOrigin: origin,\n expectedRPID: rpID,\n requireUserVerification: false, // 'preferred' = no podemos requerirlo\n });\n\n const { verified, registrationInfo } = verification;\n\n if (verified && registrationInfo) {\n const {\n credentialPublicKey,\n credentialID,\n counter,\n credentialDeviceType,\n credentialBackedUp,\n } = registrationInfo;\n\n await db.credentials.create({\n credentialID: Buffer.from(credentialID).toString('base64url'),\n credentialPublicKey: Buffer.from(credentialPublicKey),\n counter,\n transports: body.response.transports || [],\n userId: user.id,\n deviceType: credentialDeviceType, // 'singleDevice' o 'multiDevice'\n backedUp: credentialBackedUp, // ¿sincronizado en la nube?\n createdAt: new Date(),\n });\n }\n\n return { verified };\n}\n```\n\nObservar 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."},{"heading":"Flujo de Autenticación","level":2,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#flujo-de-autenticacin","content":"```javascript\nimport {\n generateAuthenticationOptions,\n verifyAuthenticationResponse,\n} from '@simplewebauthn/server';\n\nasync function handleAuthStart() {\n const options = await generateAuthenticationOptions({\n rpID,\n userVerification: 'preferred',\n // allowCredentials vacío = que el authenticator muestre todos los passkeys disponibles\n allowCredentials: [],\n });\n\n // Guardamos el challenge, en prod utilizar un store server-side de vida corta\n await challengeStore.set(options.challenge, {\n createdAt: Date.now(),\n expiresAt: Date.now() + 60000, // 60 segundos\n });\n\n return options;\n}\n```\n\nEl 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."},{"heading":"Autenticación Cross-Device: El Transporte Hybrid","level":2,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#autenticacin-cross-device-el-transporte-hybrid","content":"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):\n\n1. El navegador del escritorio muestra un QR code\n2. El usuario lo escanea con el celu\n3. El celu y el escritorio establecen un túnel BLE\n4. El celu pide autenticación biométrica\n5. La assertion firmada viaja de vuelta por el túnel al escritorio\n\nVos no implementás esto. El navegador y el SO manejan la capa de transporte. Pero se necesita saber que existe porque:\n\n- Es **lento**. El handshake BLE agrega 3-8 segundos encima del flujo normal.\n- Es **frágil**. Bluetooth es Bluetooth. Los datos de analytics muestran que falla en aproximadamente el 12% de los primeros intentos.\n- Los usuarios **no entienden** por qué el celular les vibra cuando están tratando de loguearse en la notebook.\n\nEl 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.\""},{"heading":"La Cuestión del Attestation","level":2,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#la-cuestin-del-attestation","content":"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 `.\n\nLa recomendación práctica, basada en múltiples implementaciones en producción: **no verificar attestation a menos que haya obligación legal.**\n\nLa verificación de attestation significa:\n\n- Mantener un store de certificados raíz para cada vendor de authenticators\n- Manejar validación de cadena de certificados\n- Lidiar con el FIDO Metadata Service (MDS) que es una aventura en sí misma\n- Algunos navegadores muestran diálogos de permise es más intimidantes cuando se pide attestation\n\nPara el 95% de las aplicaciones, ` attestation: 'none' ` es la opción correcta."},{"heading":"La Migración: Coexistencia Passwords + Passkeys","level":2,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#la-migracin-coexistencia-passwords-passkeys","content":"Las contraseñas no se pueden sacar de un saque. Acá va un enfoque por fases probado en producción:\n\n**Fase 1**: Agregar registro de passkey en configuración de cuenta. Mantener el login con contraseña como primario. Medir adopción.\n\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.\n\n**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.\n\n**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).\n\n```javascript\n// Middleware: escalar a passkey para rutas sensibles\nasync function requirePasskeyEscalation(req, res, next) {\n const user = req.user;\n const hasPasskey = await db.credentials.existsForUser(user.id);\n\n if (!hasPasskey) return next(); // sin passkey = fallback a sesión\n\n const lastPasskeyAuth = req.session.lastPasskeyVerification;\n const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;\n\n if (!lastPasskeyAuth || lastPasskeyAuth < fiveMinutesAgo) {\n return res.status(403).json({\n error: 'passkey_required',\n message: 'Verificá con tu passkey para continuar.',\n });\n }\n\n next();\n}\n```"},{"heading":"Consideraciones de Producción","level":2,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#consideraciones-de-produccin","content":"**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.\n\n**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.\n\n**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() `.\n\n```javascript\n// Conditional UI: passkey en el dropdown de autofill\nif (await PublicKeyCredential.isConditionalMediationAvailable()) {\n const options = await fetchAuthOptions();\n const assertion = await navigator.credentials.get({\n publicKey: options,\n mediation: 'conditional', // Acá está la magia\n });\n await verifyOnServer(assertion);\n}\n```"},{"heading":"Métricas Después de 6 Meses","level":2,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#mtricas-despus-de-6-meses","content":"Después de hacer el rollout a ~40k usuarios:\n\n- 62% de usuarios activos registraron al menos un passkey\n- El tiempo promedio de autenticación bajó de 14 segundos (email + contraseña + 2FA) a 3 segundos\n- Los tickets de bloqueo de cuenta bajaron un 78%\n- Cero incidentes de credential stuffing (antes ~2/mes pegando en el WAF)\n\nEl 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."},{"heading":"Hacia Dónde Va Esto","level":2,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#hacia-dnde-va-esto","content":"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.\n\nLos 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.\n\nEl 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."},{"heading":"Preguntas frecuentes","level":2,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#questions","content":""},{"heading":"¿Pueden los passkeys reemplazar completamente a las contraseñas hoy?","level":3,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#question-1","content":"Para la mayoría de las apps de consumo, sí. La cobertura en iOS 16+, Android 9+ y navegadores modernos es suficiente. Enterprise es más complicado por las políticas de dispositivos administrados e integraciones LDAP legacy."},{"heading":"¿Qué pasa cuando un usuario pierde todos sus dispositivos?","level":3,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#question-2","content":"Este es el problema difícil. Necesitás un flujo de recuperación de cuenta, típicamente magic link por email o SMS OTP como fallback. Los passkeys sincronizados vía iCloud Keychain o Google Password Manager mitigan la pérdida de un solo dispositivo."},{"heading":"¿Vale la pena verificar attestation en producción?","level":3,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#question-3","content":"Para la mayoría de las apps, no. Attestation te dice el modelo del authenticator pero agrega complejidad y preocupaciones de privacidad. Solo forzalo si se está en una industria regulada que exige hardware específico (como banca con FIPS 140-2)."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#article-tips","content":"1. **Tip:** Siempre iniciar con ` authenticatorAttachment: 'platform' ` y hacé fallback a ` 'cross-platform' `, los usuarios prefieren el biométrico del celu antes que enchufar una llave de hardware.\n2. **Tip:** Guardá el ` credentialID ` como base64url, no como bytes crudos. Cada driver de base de datos maneja los blobs binarios distinto y resultará problemático en la migración.\n3. **Tip:** Utilizar ` userVerification: 'preferred' ` en vez de ` 'required' ` en producción, algunos authenticators viejos fallan silenciosamente con ` 'required' ` y tus usuarios ven una pantalla en blanco.\n4. **Tip:** Probar los flujos cross-device en hardware real. El transporte Hybrid (caBLE) entre Android y macOS no se comporta ni ahí como sugiere el emulador de Chrome DevTools.\n\n- passkeys\n- webauthn\n- fido2\n- passwordless\n- autenticación\n- seguridad\n- criptografía\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)"}],"links":[{"text":"English","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation"},{"text":"@simplewebauthn/server","url":"https://simplewebauthn.dev/"},{"text":"Volver al Blog","url":"https://lucioduran.com/es/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation"},"headline":"Passkeys y WebAuthn: Guía de Implementación en Producción para Autenticación FIDO2 sin Contraseña","description":"Un recorrido sin filtro por la implementación de passkeys FIDO2 con WebAuthn, credenciales residentes, autenticación cross-device, ceremonias de attestation, y todos los edge cases que te van a arruinar el finde.","datePublished":"2025-12-29","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation","inLanguage":"es","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Security","keywords":"passkeys, webauthn, fido2, passwordless, autenticación, seguridad, criptografía","wordCount":1778,"timeRequired":"PT10M","workTranslation":[{"@type":"BlogPosting","inLanguage":"en","url":"https://lucioduran.com/blog/passkeys-webauthn-passwordless-implementation"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Inicio","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/es/blog"},{"@type":"ListItem","position":3,"name":"Passkeys y WebAuthn: Guía de Implementación en Producción para Autenticación FIDO2 sin Contraseña","item":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"¿Pueden los passkeys reemplazar completamente a las contraseñas hoy?","acceptedAnswer":{"@type":"Answer","text":"Para la mayoría de las apps de consumo, sí. La cobertura en iOS 16+, Android 9+ y navegadores modernos es suficiente. Enterprise es más complicado por las políticas de dispositivos administrados e integraciones LDAP legacy."}},{"@type":"Question","name":"¿Qué pasa cuando un usuario pierde todos sus dispositivos?","acceptedAnswer":{"@type":"Answer","text":"Este es el problema difícil. Necesitás un flujo de recuperación de cuenta, típicamente magic link por email o SMS OTP como fallback. Los passkeys sincronizados vía iCloud Keychain o Google Password Manager mitigan la pérdida de un solo dispositivo."}},{"@type":"Question","name":"¿Vale la pena verificar attestation en producción?","acceptedAnswer":{"@type":"Answer","text":"Para la mayoría de las apps, no. Attestation te dice el modelo del authenticator pero agrega complejidad y preocupaciones de privacidad. Solo forzalo si se está en una industria regulada que exige hardware específico (como banca con FIPS 140-2)."}}]}]}],"contentHash":"38d94835a7d0ec822d64eea37bdede91649aed2b8da7615c982dc2692476c375","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/es/blog/passkeys-webauthn-passwordless-implementation","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":70560,"contentBytes":15474}}
