Passkeys y WebAuthn: la guía definitiva para implementar autenticación sin contraseñas
Las passkeys ya no son una promesa: son el método de login por defecto en miles de sitios. Guía práctica de attestation, assertion, soporte real por navegador y cómo integrarlas con una librería en vez de reinventar WebAuthn desde cero.
Implementar WebAuthn desde los primitivos criptográficos que expone el navegador es perfectamente posible y una idea razonablemente mala: hay demasiados detalles donde un error de validación —un origen mal comprobado, un challenge reutilizado, un contador de firmas ignorado— convierte una autenticación “sin contraseña” en una autenticación sin protección real. La buena noticia es que en 2026 nadie serio necesita hacerlo a mano. Este artículo explica lo justo del funcionamiento interno para que entiendas qué estás verificando, y se centra en la parte que de verdad toca resolver: integrar el flujo completo de registro y login con una librería madura, entender qué soporte tienen las passkeys hoy en navegadores y dispositivos reales, y evitar los errores de implementación que sí dependen de ti aunque uses una librería.
Si vienes buscando el contexto más amplio —por qué OAuth 2.1 y las passkeys se han convertido en el estándar de facto de la autenticación moderna, o qué tan rápido está creciendo la adopción a nivel global—, esa parte está cubierta en autenticación moderna: OAuth 2.1, passkeys y el fin (relativo) de las contraseñas. Aquí el foco es exclusivamente la implementación.
Lo mínimo que necesitas entender antes de escribir código
Una passkey es un par de claves asimétricas generado y custodiado por el propio dispositivo del usuario (el enclave seguro de un móvil, el TPM de un portátil, o una llave física). La clave privada nunca sale de ese almacén, ni siquiera durante el login: lo único que viaja hacia tu servidor es la clave pública (en el registro) y una firma criptográfica (en cada login posterior). Esto elimina de raíz dos familias enteras de problemas que arrastra cualquier sistema basado en contraseñas: no hay secreto compartido que robar de una base de datos filtrada, y no hay contraseña que un usuario pueda reutilizar en otro sitio comprometido.
WebAuthn formaliza esto en dos “ceremonias” —el término que usa la propia especificación del W3C—, cada una con su propio objeto de entrada y salida:
- Attestation (registro). Tu servidor genera un
challengealeatorio y se lo pasa al navegador junto con metadatos de la cuenta. El autenticador del usuario crea un nuevo par de claves, firma el challenge y devuelve la clave pública, el ID de la credencial y datos de attestation sobre el propio autenticador. Tu servidor verifica la firma y guarda la clave pública asociada a esa cuenta. - Assertion (login). Tu servidor genera un nuevo
challenge. El autenticador firma ese challenge con la clave privada que nunca ha salido del dispositivo. Tu servidor verifica la firma con la clave pública que ya tenía guardada de un registro anterior.
sequenceDiagram participant N as Navegador (WebAuthn API) participant A as Autenticador (dispositivo) participant S as Servidor (Relying Party) S->>N: generateAuthenticationOptions() → challenge + rpId N->>A: navigator.credentials.get(options) A->>A: Firma el challenge con la clave privada del dispositivo A->>N: Firma + credentialId N->>S: verifyAuthenticationResponse() S->>S: Verifica firma con la clave pública guardada
Dos campos merecen atención especial porque son la causa más habitual de bugs de integración: el RP ID (Relying Party ID, normalmente tu dominio efectivo, sin protocolo ni puerto) ata cada credencial a un origen concreto —es lo que hace que el phishing con un dominio parecido no funcione, porque el navegador ni siquiera ofrece la passkey en un origen distinto—, y el challenge debe ser aleatorio, de un solo uso y verificado en el lado servidor, nunca solo confiado porque “viene firmado”.
Soporte real en 2026: mejor que hace dos años, todavía desigual
La API cliente de WebAuthn (navigator.credentials.create() y .get()) está soportada de forma prácticamente universal en navegadores modernos. Lo que sigue siendo desigual es el soporte de funcionalidades más recientes que determinan la calidad de la experiencia:
- Conditional UI / autocompletado de passkeys (el cuadro de login que sugiere la passkey guardada sin que el usuario pulse un botón “Iniciar sesión con passkey” explícito) está disponible en Safari 18+ (macOS e iOS/iPadOS), Chrome 136+ en escritorio y Chrome 142+ en Android, según el benchmark de Corbado de 2026.
- La FIDO Alliance reporta más de 15.000 millones de cuentas de usuario con passkeys disponibles a nivel global y más de 1.000 millones de activaciones reales en plataformas de consumo.
- La adopción por parte de los propios sitios web es donde más se nota la desigualdad: alrededor de un 50-60% de los cien sitios más visitados del mundo ofrecen passkeys, un 20-25% entre los mil más visitados, y solo un 5-10% entre los diez mil más visitados. Cuanto más pequeño el equipo, menos probable que ya lo haya implementado.
- Por sector, fintech lidera con cerca de un 60% de adopción, frente a un 35% en ecommerce, 28% en B2B SaaS y 18% en medios.
Implementarlo con una librería: por qué y cuál
La razón para no implementar la verificación de firmas de attestation y assertion a mano no es pereza: es que la especificación WebAuthn involucra codificación CBOR, estructuras COSE para las claves públicas, y varios formatos de attestation distintos según el fabricante del autenticador. Un error de parsing en cualquiera de esas capas no falla de forma ruidosa, falla de forma silenciosa y potencialmente insegura.
SimpleWebAuthn es la librería de referencia en el ecosistema JavaScript/TypeScript: mantiene un paquete de servidor (@simplewebauthn/server) que abstrae toda la verificación criptográfica, y un paquete de cliente (@simplewebauthn/browser) que envuelve las llamadas nativas a navigator.credentials. Existen equivalentes maduros para otros lenguajes (webauthn4j en Java, py_webauthn en Python, webauthn-rs en Rust), pero el patrón de integración es el mismo en todos: tu backend nunca toca bytes CBOR directamente, solo llama a funciones de alto nivel y persiste el resultado.
Registro: generar opciones, verificar la respuesta
// backend/webauthn/register.ts
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
const rpID = 'programacionwebs.com';
const rpName = 'ProgramacionWebs';
export async function startRegistration(userId: string, username: string) {
const existingCredentials = await getCredentialsForUser(userId);
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: username,
// Evita que el mismo autenticador registre dos credenciales para el mismo usuario
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: 'required', // credencial "discoverable": permite login sin escribir usuario
userVerification: 'preferred',
},
});
// El challenge debe guardarse asociado a la sesión, nunca confiar en el que devuelva el cliente
await saveChallengeForUser(userId, options.challenge);
return options;
}
export async function finishRegistration(userId: string, response: unknown) {
const expectedChallenge = await getChallengeForUser(userId);
const verification = await verifyRegistrationResponse({
response: response as any,
expectedChallenge,
expectedOrigin: 'https://programacionwebs.com',
expectedRPID: rpID,
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error('No se pudo verificar el registro de la passkey');
}
const { credential } = verification.registrationInfo;
await saveCredential(userId, {
credentialId: credential.id,
publicKey: credential.publicKey,
counter: credential.counter,
transports: response.response?.transports,
});
}
Login: la misma estructura, sin necesidad de usuario previo
// backend/webauthn/authenticate.ts
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
export async function startAuthentication() {
// Sin allowCredentials: el navegador ofrece cualquier passkey discoverable
// guardada para este dominio (login "usernameless")
const options = await generateAuthenticationOptions({
rpID: 'programacionwebs.com',
userVerification: 'preferred',
});
await saveChallenge(options.challenge);
return options;
}
export async function finishAuthentication(response: unknown) {
const credential = await getCredentialById(response.id);
if (!credential) throw new Error('Credencial no reconocida');
const expectedChallenge = await getChallenge();
const verification = await verifyAuthenticationResponse({
response: response as any,
expectedChallenge,
expectedOrigin: 'https://programacionwebs.com',
expectedRPID: 'programacionwebs.com',
credential: {
id: credential.credentialId,
publicKey: credential.publicKey,
counter: credential.counter,
},
});
if (!verification.verified) throw new Error('Firma inválida');
// Actualiza el contador para detectar credenciales clonadas
await updateCredentialCounter(credential.credentialId, verification.authenticationInfo.newCounter);
return credential.userId;
}
En el navegador, el trabajo se reduce a dos llamadas de @simplewebauthn/browser, que ya gestionan la conversión entre los formatos que espera navigator.credentials y JSON:
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
async function registerPasskey(userId: string) {
const options = await fetch(`/api/webauthn/register/options?userId=${userId}`).then((r) => r.json());
const attestationResponse = await startRegistration({ optionsJSON: options });
await fetch('/api/webauthn/register/verify', {
method: 'POST',
body: JSON.stringify({ userId, response: attestationResponse }),
});
}
async function loginWithPasskey() {
const options = await fetch('/api/webauthn/authenticate/options').then((r) => r.json());
const assertionResponse = await startAuthentication({ optionsJSON: options });
const result = await fetch('/api/webauthn/authenticate/verify', {
method: 'POST',
body: JSON.stringify({ response: assertionResponse }),
});
return result.json();
}
Errores de implementación que la librería no te evita
Usar SimpleWebAuthn o cualquier librería equivalente elimina los errores criptográficos, pero deja en tus manos varias decisiones que sí pueden romper la seguridad del flujo:
- Validar
expectedOriginyexpectedRPIDcontra valores fijos del servidor, nunca contra lo que llegue en la petición. Si aceptas el origen que te manda el cliente “porque es más flexible para desarrollo”, cualquier atacante puede pasar el origen que quiera. - No reutilizar el challenge. Debe generarse por intento, asociarse a la sesión (o a un almacén con expiración corta) y descartarse tras usarse una vez, se verifique con éxito o no.
- Actualizar el contador de firmas tras cada login exitoso. Un contador que no avanza —o que retrocede— es la señal clásica de una credencial clonada o de un autenticador comprometido. Muchas passkeys modernas basadas en sincronización en la nube ya no incrementan el contador de forma fiable (el propio W3C lo contempla), así que trátalo como una señal adicional, no como la única defensa.
- Diseñar la recuperación de cuenta antes de lanzar la función a producción. Sin un plan de respaldo —correo de verificación, códigos de un solo uso, un segundo método vinculado—, un usuario que pierde su único dispositivo con la passkey queda bloqueado sin remedio.
Passkeys como segundo factor antes que como reemplazo total
No hace falta eliminar la contraseña el primer día para obtener valor de WebAuthn. Un camino de adopción habitual y de bajo riesgo:
- Ofrece registrar una passkey justo después de un login exitoso con el método existente, en el momento de mayor confianza de la sesión.
- Úsala primero como segundo factor (en vez de un código TOTP) antes de plantear sustituir el login principal.
- Mide la tasa de éxito real de tus propios usuarios —dispositivo, navegador, tasa de abandono— antes de ocultar la opción de contraseña para nadie.
- No fuerces la migración completa hasta que la recuperación de cuenta esté probada de verdad, incluyendo el caso “perdí todos mis dispositivos”.
Para el diseño más amplio de qué combinar con esto —OAuth 2.1 para autorización delegada entre servicios, autenticación de API con tokens— conviene revisar también seguridad en APIs: autenticación, autorización y los errores más comunes, que cubre la parte del problema que WebAuthn no resuelve: qué puede hacer un usuario ya autenticado, no solo quién es.
Artículos relacionados
Autenticación moderna: OAuth 2.1, passkeys y el fin (relativo) de las contraseñas
OAuth 2.1 consolida una década de buenas prácticas de seguridad en un único documento, y las passkeys ya superan a la contraseña en tasa de éxito de login. Qué implica esto para tu backend.
Seguridad en APIs: autenticación, autorización y los errores más comunes
Comprobar que un token es válido no es lo mismo que comprobar que su dueño puede acceder a ese recurso concreto. La confusión entre ambas cosas explica el fallo más repetido en auditorías de APIs: BOLA. Guía práctica para diseñarlo bien desde cero.
Rate limiting y protección de APIs: patrones de producción
El rate limiting mal implementado da una falsa sensación de seguridad. Repasamos los algoritmos que se usan de verdad en producción, dónde aplicarlos, los headers estándar y los fallos que lo dejan sin efecto.