Back

Passkeys y WebAuthn: La Guía Completa para Eliminar las Contraseñas de Tu App Web

Tus usuarios siguen tipeando contraseñas. En 2026. Aunque cada plataforma grande — Apple, Google, Microsoft — ya soporta passkeys, la mayoría de las apps web siguen atrapadas en la misma arquitectura de autenticación de 2005: hashear una contraseña, guardarla en la base de datos y rezar para que nadie la encuentre.

No es que los passkeys sean difíciles de entender. Es que el camino de implementación está lleno de trampas sin documentar, especificaciones confusas, y una API de WebAuthn que se ve simple en los demos pero se rompe en producción. ¿Qué hace exactamente allowCredentials? ¿Por qué navigator.credentials.get() falla silenciosamente en algunos navegadores? ¿Cómo migrás 500.000 usuarios existentes sin romper sus sesiones?

Esta guía cubre todo lo que necesitás para llevar passkeys a producción — desde los fundamentos criptográficos hasta implementaciones completas en TypeScript, integración con Conditional UI, diseño de esquema de base de datos, sincronización multi-dispositivo, y la estrategia de migración por fases que te permite hacer la transición sin forzar a los usuarios a cambiar su comportamiento de un día para el otro.


Por Qué las Contraseñas Siguen Siendo un Riesgo

Los números hablan solos. Según el informe Verizon DBIR 2025, más del 80% de las brechas en aplicaciones web se originan en credenciales robadas o débiles. Los ataques de phishing funcionan porque las contraseñas son un secreto compartido — el servidor lo sabe, el usuario lo sabe, y cualquiera que lo intercepte en tránsito o en reposo tiene acceso total.

Lo Que los Passkeys Realmente Resuelven

Los passkeys eliminan el secreto compartido por completo. La diferencia fundamental:

Autenticación con contraseña:
  Usuario → envía contraseña → Servidor compara hash
  Superficie de ataque: phishing, credential stuffing, brecha de BD

Autenticación con passkey:
  Usuario → firma challenge con clave privada → Servidor verifica con clave pública
  Superficie de ataque: robo físico del dispositivo (requiere biométrico)

La clave privada nunca sale del dispositivo del usuario. El servidor solo almacena la clave pública. Incluso si tu base de datos entera se filtra, los atacantes no obtienen nada útil — las claves públicas no sirven para autenticarse.

Las Tres Propiedades Clave

  1. Resistente al phishing. Los passkeys están criptográficamente vinculados a tu dominio (el "Relying Party ID"). Una página de login falsa en evil-example.com no puede físicamente disparar un passkey creado para example.com. El navegador lo impide a nivel de protocolo, ninguna cantidad de ingeniería social lo evita.

  2. Sin secretos compartidos. Tu servidor nunca ve, almacena ni transmite ningún material secreto. No hay nada que hashear, nada que saltear, nada que filtrar. Los ataques de credential stuffing se vuelven irrelevantes porque no hay credenciales que reutilizar.

  3. Multi-factor incorporado. La autenticación con passkey combina "algo que tenés" (el dispositivo) con "algo que sos" (biométrico) o "algo que sabés" (PIN del dispositivo). Obtenés seguridad de MFA sin pedirle a los usuarios que instalen una app de autenticación.


Entendiendo el Flujo de WebAuthn

La Web Authentication API (WebAuthn) define dos ceremonias principales: Registro (crear una credencial) y Autenticación (usarla para iniciar sesión).

Flujo de Registro

┌──────────┐         ┌──────────┐         ┌──────────────┐
│ Navegador │         │ Servidor  │         │ Autenticador  │
└────┬─────┘         └────┬─────┘         └──────┬───────┘
     │  1. Iniciar Registro│                      │
     │ ──────────────────> │                      │
     │                     │                      │
     │  2. Challenge + Opts│                      │
     │ <────────────────── │                      │
     │                     │                      │
     │  3. Crear Credencial│                      │
     │ ──────────────────────────────────────────>│
     │                     │                      │
     │  4. Prompt Biométr. │                      │
     │ <──────────────────────────────────────────│
     │                     │                      │
     │  5. Clave Pública   │                      │
     │ ──────────────────> │                      │
     │                     │                      │
     │  6. Verificar       │                      │
     │ <────────────────── │                      │

Flujo de Autenticación

┌──────────┐         ┌──────────┐         ┌──────────────┐
│ Navegador │         │ Servidor  │         │ Autenticador  │
└────┬─────┘         └────┬─────┘         └──────┬───────┘
     │  1. Iniciar Login   │                      │
     │ ──────────────────> │                      │
     │                     │                      │
     │  2. Challenge       │                      │
     │ <────────────────── │                      │
     │                     │                      │
     │  3. Firmar Challenge│                      │
     │ ──────────────────────────────────────────>│
     │                     │                      │
     │  4. Prompt Biométr. │                      │
     │ <──────────────────────────────────────────│
     │                     │                      │
     │  5. Assertion Firmada│                     │
     │ ──────────────────> │                      │
     │                     │                      │
     │  6. Verificar Firma │                      │
     │ <────────────────── │                      │

El detalle crucial: en el paso 5, el autenticador firma el challenge del servidor con la clave privada. El servidor luego verifica esa firma contra la clave pública almacenada. No se transmite ningún secreto — solo una prueba de que el usuario posee la clave privada.


Implementación Completa con SimpleWebAuthn

Hacer tu propia implementación de WebAuthn desde cero es garantía de bugs de seguridad sutiles. El parsing de CBOR, la verificación de attestation y el manejo de challenges son lo suficientemente complejos como para que las bibliotecas probadas sean el único camino razonable. SimpleWebAuthn es la librería TypeScript más adoptada para esto.

Setup del Proyecto

# Server-side npm install @simplewebauthn/server # Client-side npm install @simplewebauthn/browser

Esquema de Base de Datos

Antes de escribir código de auth, diseñá la capa de almacenamiento. Necesitás dos tablas:

-- Tabla de usuarios CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username VARCHAR(255) UNIQUE NOT NULL, display_name VARCHAR(255), -- Campos legacy de password (mantener durante migración) password_hash VARCHAR(255), created_at TIMESTAMPTZ DEFAULT NOW() ); -- Tabla de credenciales passkey (un usuario puede tener múltiples passkeys) CREATE TABLE passkey_credentials ( id VARCHAR(512) PRIMARY KEY, -- credential ID (base64url) user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, public_key BYTEA NOT NULL, -- almacenada como raw bytes counter BIGINT NOT NULL DEFAULT 0, -- contador de firmas device_type VARCHAR(50) NOT NULL, -- 'singleDevice' o 'multiDevice' backed_up BOOLEAN NOT NULL DEFAULT false, -- ¿sincronizado en la nube? transports TEXT[], -- ['internal', 'hybrid', etc.] display_name VARCHAR(255), -- "MacBook Pro Touch ID" created_at TIMESTAMPTZ DEFAULT NOW(), last_used_at TIMESTAMPTZ ); CREATE INDEX idx_credentials_user_id ON passkey_credentials(user_id);

Decisiones clave de diseño:

  • Relación uno-a-muchos. Un usuario puede registrar múltiples passkeys (celular, laptop, security key). Nunca limites a uno.
  • Campo counter. Los autenticadores incrementan un contador de firma con cada uso. Si llega una firma con un contador menor al almacenado, la credencial fue clonada — rechazá y marcá inmediatamente.
  • device_type y backed_up. Te dicen si el passkey está sincronizado entre dispositivos (iCloud Keychain, Google Password Manager) o vinculado a un solo dispositivo (hardware security key).
  • Array transports. Almacena cómo se comunica el autenticador ('internal' para biométrico de plataforma, 'hybrid' para QR cross-device, 'usb' para security keys). Esto acelera autenticaciones posteriores dándole hints al navegador sobre qué transporte probar primero.

Servidor: Endpoint de Registro

import { generateRegistrationOptions, verifyRegistrationResponse, type VerifiedRegistrationResponse, } from '@simplewebauthn/server'; const RP_NAME = 'My App'; const RP_ID = 'example.com'; const ORIGIN = 'https://example.com'; // Paso 1: Generar opciones app.post('/api/auth/register/begin', async (req, res) => { const user = await getUserFromSession(req); const existingCredentials = await db.query( 'SELECT id, transports FROM passkey_credentials WHERE user_id = $1', [user.id] ); const options = await generateRegistrationOptions({ rpName: RP_NAME, rpID: RP_ID, userName: user.username, userDisplayName: user.display_name || user.username, excludeCredentials: existingCredentials.rows.map(cred => ({ id: cred.id, transports: cred.transports, })), authenticatorSelection: { residentKey: 'required', // CRÍTICO: esto lo convierte en passkey userVerification: 'preferred', }, attestationType: 'none', }); await setSessionChallenge(req, options.challenge); res.json(options); }); // Paso 2: Verificar respuesta app.post('/api/auth/register/complete', async (req, res) => { const user = await getUserFromSession(req); const expectedChallenge = await getSessionChallenge(req); try { const verification: VerifiedRegistrationResponse = await verifyRegistrationResponse({ response: req.body, expectedChallenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID, }); if (!verification.verified || !verification.registrationInfo) { return res.status(400).json({ error: 'Verificación fallida' }); } const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; await db.query( `INSERT INTO passkey_credentials (id, user_id, public_key, counter, device_type, backed_up, transports) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ credential.id, user.id, Buffer.from(credential.publicKey), credential.counter, credentialDeviceType, credentialBackedUp, credential.transports ?? [], ] ); res.json({ verified: true }); } catch (error) { console.error('Falló la verificación de registro:', error); res.status(400).json({ error: 'Registro fallido' }); } });

Servidor: Endpoint de Autenticación

import { generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server'; app.post('/api/auth/login/begin', async (req, res) => { const options = await generateAuthenticationOptions({ rpID: RP_ID, userVerification: 'preferred', allowCredentials: [], }); await setChallengeStore(options.challenge, { expiresIn: 300 }); res.json(options); }); app.post('/api/auth/login/complete', async (req, res) => { const { id: credentialId } = req.body; const credRow = await db.query( `SELECT pc.*, u.id as uid, u.username FROM passkey_credentials pc JOIN users u ON pc.user_id = u.id WHERE pc.id = $1`, [credentialId] ); if (credRow.rows.length === 0) { return res.status(401).json({ error: 'Credencial desconocida' }); } const cred = credRow.rows[0]; const expectedChallenge = await getChallengeStore(req.body.response.clientDataJSON); try { const verification = await verifyAuthenticationResponse({ response: req.body, expectedChallenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID, credential: { id: cred.id, publicKey: new Uint8Array(cred.public_key), counter: Number(cred.counter), transports: cred.transports, }, }); if (!verification.verified) { return res.status(401).json({ error: 'Verificación fallida' }); } const { newCounter } = verification.authenticationInfo; await db.query( `UPDATE passkey_credentials SET counter = $1, last_used_at = NOW() WHERE id = $2`, [newCounter, credentialId] ); const token = await createSession(cred.uid); res.json({ verified: true, token }); } catch (error) { console.error('Autenticación fallida:', error); res.status(401).json({ error: 'Autenticación fallida' }); } });

Cliente: Integración con el Navegador

import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; async function registerPasskey(): Promise<void> { const optionsRes = await fetch('/api/auth/register/begin', { method: 'POST' }); const options = await optionsRes.json(); const credential = await startRegistration({ optionsJSON: options }); const verifyRes = await fetch('/api/auth/register/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credential), }); const result = await verifyRes.json(); if (result.verified) { console.log('Passkey registrado exitosamente'); } } async function loginWithPasskey(): Promise<void> { const optionsRes = await fetch('/api/auth/login/begin', { method: 'POST' }); const options = await optionsRes.json(); const assertion = await startAuthentication({ optionsJSON: options }); const verifyRes = await fetch('/api/auth/login/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(assertion), }); const result = await verifyRes.json(); if (result.verified) { window.location.href = '/dashboard'; } }

Conditional UI: Passkeys en el Menú de Autocompletar

La mejora de UX más grande de los passkeys no es el flujo de registro — es la Conditional UI. En vez de requerir que los usuarios hagan clic en un botón "Iniciar sesión con passkey", el navegador muestra los passkeys disponibles directamente en el dropdown de autocompletar del campo de usuario, al lado de las contraseñas guardadas.

Cómo Funciona

<form> <input type="text" id="username" name="username" autocomplete="username webauthn" placeholder="Email o nombre de usuario" /> <input type="password" name="password" autocomplete="current-password" /> <button type="submit">Iniciar Sesión</button> </form>

El token webauthn en el atributo autocomplete le dice al navegador que incluya opciones de passkey en el dropdown. Tiene que ser el último token en el valor del atributo.

Implementación

import { startAuthentication, browserSupportsWebAuthnAutofill } from '@simplewebauthn/browser'; async function initConditionalUI(): Promise<void> { const supported = await browserSupportsWebAuthnAutofill(); if (!supported) { console.log('Conditional UI no soportado, cayendo a botón'); return; } const optionsRes = await fetch('/api/auth/login/begin', { method: 'POST' }); const options = await optionsRes.json(); try { const assertion = await startAuthentication({ optionsJSON: options, useBrowserAutofill: true, }); const verifyRes = await fetch('/api/auth/login/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(assertion), }); const result = await verifyRes.json(); if (result.verified) { window.location.href = '/dashboard'; } } catch (error) { if ((error as Error).name !== 'AbortError') { console.error('Error de Conditional UI:', error); } } } document.addEventListener('DOMContentLoaded', initConditionalUI);

Detalles Críticos de Implementación

1. Llamalo al cargar la página, no al hacer clic. La Conditional UI tiene que iniciarse cuando la página carga. El navegador escucha continuamente hasta que el usuario interactúa con el dropdown de autocompletar. Si solo lo llamás al hacer clic en un botón, perdés la experiencia fluida de autocompletar.

2. Usá un AbortController. Si el usuario cambia a otro método de login (social, magic link), abortá el request pendiente de Conditional UI para evitar errores de "request already pending".

3. Estrategia de fallback. Siempre dejá visible un botón "Iniciar sesión con passkey" para navegadores que no soporten Conditional UI. El flujo modal estándar funciona como fallback.


Estrategia de Migración por Fases

No podés apretar un switch y borrar todas las contraseñas de un día para el otro. Acá va el enfoque de tres fases que funciona en producción:

Fase 1: Introducción (Enrolamiento Pasivo)

Después de un login exitoso con contraseña, proponé registrar un passkey. Sin forzar — solo hacé que la opción sea visible y atractiva.

async function postLoginPasskeyPrompt(user: User): Promise<void> { const hasPasskey = await userHasPasskey(user.id); if (hasPasskey) return; const dismissedCount = await getPromptDismissals(user.id); if (dismissedCount >= 3) return; showPasskeyEnrollmentBanner({ title: 'Activá el inicio de sesión rápido', description: 'Usá Face ID o tu huella en vez de la contraseña', onAccept: () => registerPasskey(), onDismiss: () => incrementPromptDismissals(user.id), }); }

Fase 2: Por Defecto (Nuevos Usuarios Arrancan Passwordless)

El registro de nuevos usuarios arranca con passkey. La contraseña está disponible pero como opción secundaria.

async function registerNewUser(userData: NewUser): Promise<void> { const user = await createUser(userData); // Verificar soporte de WebAuthn primero const webauthnSupported = await isWebAuthnSupported(); if (webauthnSupported) { await registerPasskey(); // Primario — passkey // Opcionalmente todavía recoger password como backup } else { await setPassword(user.id, userData.password); // Fallback } }

Fase 3: Transición (Opción Solo Passwordless)

Los usuarios con passkeys registrados pueden optar por deshabilitar su contraseña.

app.post('/api/auth/disable-password', async (req, res) => { const user = await getUserFromSession(req); const credentials = await getUserCredentials(user.id); if (credentials.length < 2) { return res.status(400).json({ error: 'Registrá al menos 2 passkeys antes de deshabilitar la contraseña' }); } const hasSyncedPasskey = credentials.some(c => c.backed_up); if (!hasSyncedPasskey) { return res.status(400).json({ error: 'Registrá al menos un passkey sincronizado (iCloud/Google) para recuperación' }); } await db.query( 'UPDATE users SET password_hash = NULL WHERE id = $1', [user.id] ); res.json({ success: true }); });

Métricas de Migración

MétricaObjetivoQué Te Dice
Tasa de adopción de passkeys>30% en 6 mesesVelocidad general de migración
Completación de registro>80% de los que empiezanFricción en el UX de enrolamiento
Tasa de éxito de auth>99%Confiabilidad del flujo passkey
Uso de fallbackTendencia descendenteSi los usuarios se están alejando de contraseñas
Tasa de dismiss del prompt<60%Si los prompts de enrolamiento son demasiado agresivos

Trampas de Seguridad en Producción

1. Ataques de Replay de Challenge

Cada challenge debe ser de un solo uso y tener tiempo limitado. Si guardás challenges en un JWT stateless, un atacante puede replayear el mismo challenge.

// MAL: Challenge en una cookie firmada (replayable) const challenge = signJWT({ challenge: randomBytes(32) }); // BIEN: Challenge en store server-side con TTL const challenge = randomBytes(32).toString('base64url'); await redis.setex(`webauthn:challenge:${sessionId}`, 300, challenge); // Después de verificar, borrar inmediatamente await redis.del(`webauthn:challenge:${sessionId}`);

2. Validación de Contador

El contador de firma es tu defensa contra la clonación de credenciales. Si el contador va para atrás, algo está muy mal.

async function validateCounter( credentialId: string, newCounter: number ): Promise<void> { const stored = await getStoredCounter(credentialId); if (newCounter > 0 && newCounter <= stored) { await flagCredentialAsCompromised(credentialId); await notifySecurityTeam({ event: 'counter_regression', credentialId, storedCounter: stored, receivedCounter: newCounter, }); throw new Error('Regresión de contador detectada'); } }

Nota: Algunos autenticadores de plataforma (especialmente passkeys sincronizados) siempre reportan un contador de 0. En ese caso, la validación de contador está deshabilitada de facto. Solo marcá regresiones cuando el contador está siendo usado activamente (es decir, cuando es mayor a 0).

3. Validación de Origin

Siempre validá el origin de forma estricta. Un chequeo de origin mal configurado puede anular la resistencia al phishing que hace valiosos a los passkeys.

// MAL: Match por substring (vulnerable a ataques de subdominio) if (origin.includes('example.com')) { ... } // BIEN: Match exacto contra origins permitidos const ALLOWED_ORIGINS = [ 'https://example.com', 'https://app.example.com', ]; if (!ALLOWED_ORIGINS.includes(origin)) { throw new Error('Origin no permitido'); }

4. Recuperación de Cuenta

El dolor de cabeza más grande de la autenticación passwordless: ¿qué pasa cuando un usuario pierde todos sus dispositivos?

interface RecoveryStrategy { minimumPasskeys: 2; requireSyncedCredential: true; fallbacks: [ 'recovery_codes', 'email_magic_link', 'identity_verification' ]; }

Generá códigos de recuperación durante el registro inicial del passkey e indicale a los usuarios que los guarden de forma segura. Es el mismo patrón que las security keys usan hace años — funciona.


Consideraciones Cross-Platform

Comportamiento de Sincronización

PlataformaMecanismo de SyncAlcance
AppleiCloud KeychainTodos los dispositivos Apple con el mismo Apple ID
GoogleGoogle Password ManagerChrome/Android con la misma cuenta Google
MicrosoftMicrosoft AuthenticatorDispositivos Windows con la misma cuenta MS
1Password / BitwardenVault de tercerosCross-platform, todos los dispositivos con acceso

Flujo Cross-Device (Transporte Hybrid)

Cuando un usuario quiere loguearse en un dispositivo que no tiene su passkey, el transporte "hybrid" entra en juego:

  1. El navegador de escritorio muestra un código QR
  2. El usuario lo escanea con su celular (que tiene el passkey)
  3. El celular pide biométrico
  4. La autenticación se completa en el escritorio

Esto funciona automáticamente — no necesitás implementar nada especial. Cuando transports incluye 'hybrid', el navegador y el autenticador manejan todo el flujo.


Modelo de Performance y Costos

La autenticación con passkeys es significativamente más barata a escala que la autenticación con contraseñas:

FactorContraseñasPasskeys
Cómputo del servidorHashing bcrypt/Argon2 (CPU intensivo)Verificación Ed25519 (rápido)
Storage por usuario~128 bytes (hash + salt)~256 bytes (clave pública + metadata)
Tickets de soporte"Olvidé mi contraseña" (~40% del volumen)Casi cero después del enrolamiento
Costo de brechaPromedio $4.44M por incidenteSin credenciales útiles para robar
Costo de SMS 2FA$0.01-0.05 por mensaje$0 (verificación incorporada)

Para un servicio con 100.000 usuarios, eliminar solo los flujos de reseteo de contraseña puede ahorrar 15-20 horas de soporte por mes.


La Realidad de 2026

Los passkeys no son tecnología del futuro — son del presente. Todos los navegadores principales los soportan. Todas las plataformas principales los sincronizan. La spec de WebAuthn es estable. Librerías como SimpleWebAuthn manejan los detalles criptográficos riesgosos.

Los bloqueos reales son organizacionales, no técnicos. Los equipos dudan porque "nuestros usuarios están acostumbrados a las contraseñas" o "no podemos romper el flujo de login actual". La migración por fases resuelve eso. No necesitás deprecar contraseñas el día uno. Solo necesitás empezar a ofrecer algo mejor al lado.

Los equipos que están implementando passkeys hoy ven tasas de adopción voluntaria del 50-70% en seis meses cuando el UX de enrolamiento es fluido. Los usuarios de verdad prefieren tocar su huella a tipear una contraseña. La fricción de conversión baja, la carga de soporte cae, y la postura de seguridad mejora drásticamente.

Empezá por la Conditional UI. Requiere cambios mínimos de UI — solo un atributo autocomplete y un script al cargar la página. Los usuarios que tienen passkeys obtienen el camino rápido automáticamente. Los que no, ven el mismo formulario de login de siempre. Cero disrupción, máximo beneficio.

La contraseña es una tecnología de 60 años. Es hora de que se jubile.

AuthenticationSecurityWebAuthnPasskeysTypeScriptWeb Development

Explora herramientas relacionadas

Prueba estas herramientas gratuitas de Pockit