Saltar al contenido

Implementando Passkeys: Guía Práctica de WebAuthn

Guía práctica para implementar passkeys con WebAuthn, desde el registro hasta la verificación en el servidor.

5 min de lectura
Diagrama que ilustra el flujo de registro y autenticación de passkeys WebAuthn entre el navegador y el servidor

Las contraseñas son el eslabón más débil de la mayoría de los sistemas de autenticación, y todo el mundo lo sabe. Los usuarios las reutilizan, los kits de phishing las recolectan, y las bases de datos filtradas las exponen por miles de millones. Las passkeys, construidas sobre la especificación WebAuthn, resuelven el problema eliminando por completo los secretos compartidos de la ecuación. El navegador y un autenticador (un teléfono, una llave de seguridad o el keychain del sistema operativo) guardan una clave privada que nunca sale del dispositivo — el servidor solo llega a ver una clave pública y una firma.

La teoría ya se entiende bien a estas alturas. Lo que hace tropezar a los equipos es la implementación: la gestión de challenges, la validación del origen, el almacenamiento de credenciales y el fallback de UX cuando el navegador o dispositivo del usuario no soporta el flujo de forma limpia. Este artículo recorre una implementación con forma de producción, no una demo de juguete.

Cómo funciona realmente la ceremonia

WebAuthn define dos "ceremonias": registro y autenticación. Ambas son protocolos de challenge-respuesta mediados por la Web Authentication API del navegador.

Durante el registro, el servidor genera un challenge aleatorio, el navegador le pide al autenticador que cree un nuevo par de claves asociado a tu ID de relying party (rpId, típicamente tu dominio), y el autenticador devuelve una clave pública más un objeto de attestation. Durante la autenticación, el servidor emite un nuevo challenge, el autenticador lo firma con la clave privada almacenada, y el servidor verifica la firma contra la clave pública que guardó previamente.

texttext
Registration:  server --challenge--> browser --> authenticator
               authenticator --public key + attestation--> server (stored)
 
Authentication: server --challenge--> browser --> authenticator
                authenticator --signature--> server (verified against stored key)

Nada que se parezca a una contraseña cruza jamás la red. El challenge previene ataques de replay, y la firma demuestra la posesión de la clave privada sin revelarla.

Registrando una credencial en el cliente

La API del navegador es de bajo nivel por diseño — trabaja con ArrayBuffers, no con JSON — así que la mayoría de los equipos la envuelven. Aquí está la llamada principal, sin la librería auxiliar:

jsjavascript
// client/register.js
async function registerPasskey(username) {
  const optionsResponse = await fetch("/api/webauthn/register/options", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username }),
  });
  const options = await optionsResponse.json();
 
  // Challenge and user.id arrive as base64url strings over JSON;
  // the browser API needs raw ArrayBuffers.
  options.challenge = base64urlToBuffer(options.challenge);
  options.user.id = base64urlToBuffer(options.user.id);
 
  const credential = await navigator.credentials.create({
    publicKey: options,
  });
 
  await fetch("/api/webauthn/register/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(serializeCredential(credential)),
  });
}

La parte interesante ocurre casi por completo en el servidor: generar opciones de registro sensatas y verificar correctamente la respuesta.

Generando las opciones de registro

Un error común es tratar el challenge o el rpId como configuración estática en lugar de estado por petición que debe validarse cuando vuelve.

tstypescript
// server/webauthn/options.ts
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { randomBytes } from "node:crypto";
 
export async function buildRegistrationOptions(userId: string, username: string) {
  const existingCredentials = await credentialRepo.findByUserId(userId);
 
  const options = await generateRegistrationOptions({
    rpName: "Acme Inc",
    rpID: "acme.com", // must exactly match the origin's registrable domain
    userID: Buffer.from(userId),
    userName: username,
    attestationType: "none", // "none" is enough for most apps; skip vendor lock-in
    excludeCredentials: existingCredentials.map((cred) => ({
      id: cred.credentialId,
      transports: cred.transports,
    })),
    authenticatorSelection: {
      residentKey: "required",      // discoverable credential, enables usernameless login
      userVerification: "preferred",
    },
  });
 
  // Store the challenge server-side (Redis, session store), never trust
  // a challenge the client claims to have received.
  await challengeStore.save(userId, options.challenge, { ttlSeconds: 300 });
 
  return options;
}

residentKey: "required" es lo que convierte la credencial en una passkey verdadera en lugar de una simple llave de seguridad — es "discoverable", lo que significa que el navegador puede encontrarla sin que el servidor siquiera envíe primero un nombre de usuario.

Verificando en el camino de vuelta — ahí vive el riesgo real

Este es el paso donde la gente se equivoca. Si te saltas la validación del origen o del RP ID, has construido algo que parece WebAuthn pero no te da sus garantías.

tstypescript
// server/webauthn/verify.ts
import { verifyRegistrationResponse } from "@simplewebauthn/server";
 
export async function verifyRegistration(userId: string, body: unknown) {
  const expectedChallenge = await challengeStore.consume(userId);
  if (!expectedChallenge) {
    throw new Error("Challenge expired or already used");
  }
 
  // ❌ Bad: trusting whatever origin the client claims
  // const verification = await verifyRegistrationResponse({
  //   response: body,
  //   expectedOrigin: body.origin,
  // });
 
  // ✅ Good: origin and RP ID are fixed server-side constants
  const verification = await verifyRegistrationResponse({
    response: body as RegistrationResponseJSON,
    expectedChallenge,
    expectedOrigin: "https://acme.com",
    expectedRPID: "acme.com",
    requireUserVerification: true,
  });
 
  if (!verification.verified || !verification.registrationInfo) {
    throw new Error("Registration verification failed");
  }
 
  const { credentialID, credentialPublicKey, counter } =
    verification.registrationInfo;
 
  await credentialRepo.save({
    userId,
    credentialId: credentialID,
    publicKey: credentialPublicKey,
    signCount: counter,
    transports: body.response.transports ?? [],
  });
}

El campo counter importa más de lo que parece. Los autenticadores incrementan un contador de firmas en cada uso; si un futuro login presenta un contador menor o igual al que tienes almacenado, es una señal fuerte de un autenticador clonado y el login debería rechazarse.

Login sin nombre de usuario

La credencial discoverable que creaste arriba te permite omitir por completo el campo de nombre de usuario — el navegador le pide al usuario elegir una cuenta entre las passkeys que tiene para tu dominio.

jsjavascript
// client/login.js
async function loginWithPasskey() {
  const optionsResponse = await fetch("/api/webauthn/login/options");
  const options = await optionsResponse.json();
  options.challenge = base64urlToBuffer(options.challenge);
 
  const assertion = await navigator.credentials.get({
    publicKey: options,
    mediation: "optional", // or "conditional" for autofill-style UI
  });
 
  const result = await fetch("/api/webauthn/login/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(serializeAssertion(assertion)),
  });
 
  if (!result.ok) throw new Error("Login failed");
  return result.json();
}

Vale la pena destacar mediation: "conditional" específicamente — combinado con un atributo autocomplete="webauthn" en un campo de nombre de usuario, habilita la UI de "passkey en autofill" que Chrome y Safari muestran sin ningún botón adicional.

El soporte de plataformas todavía necesita una ruta de respaldo

!

No todos los usuarios tienen un autenticador o navegador compatible, y a veces los dispositivos gestionados corporativamente desactivan directamente los autenticadores de plataforma. Las passkeys deberían ser algo adicional, no un reemplazo total, hasta que la adopción esté más cerca de ser universal.

EscenarioFallback recomendado
Sin autenticador de plataformaFlujo entre dispositivos (QR + Bluetooth)
El navegador no soporta WebAuthnMagic link o TOTP
El usuario pierde todos los dispositivos registradosFlujo de recuperación de cuenta con verificación de identidad

No construyas el fallback de magic link o contraseña como algo añadido a último momento después del lanzamiento — diseña el modelo de cuentas desde el primer día para soportar múltiples tipos de credenciales por usuario, con las passkeys como la opción preferida y de mayor confianza.

Puntos clave

  1. Trata el challenge como estado de sesión del lado del servidor con un TTL corto — nunca confíes en un valor de challenge que te devuelva el cliente.
  2. Fija expectedOrigin y expectedRPID a valores hardcodeados; nunca los leas del cuerpo de la petición.
  3. Usa residentKey: "required" para obtener passkeys verdaderamente discoverable y habilitar el login sin nombre de usuario basado en autofill.
  4. Rastrea y valida el contador de firmas en cada autenticación para detectar autenticadores clonados.
  5. Entrega siempre un fallback que no dependa de passkeys — la recuperación de cuenta y el soporte multi-navegador no son opcionales en 2026.
  6. Almacena varias credenciales por usuario desde el inicio; "una passkey por cuenta" es una trampa de diseño que tendrás que deshacer más adelante.

Las passkeys eliminan toda una clase de ataques de robo de credenciales, pero solo si la verificación del lado del servidor es tan rigurosa como lo pretende la especificación. Vale la pena leer de principio a fin la documentación de passkeys de la FIDO Alliance antes de llevar esto a producción — los modos de fallo son sutiles, y son exactamente el tipo de cosa que se ve bien en una demo y se rompe frente a un atacante real.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX