WebAuthn y Passkeys en Node.js y Next.js: autenticación resistente al phishing [2026]
Las contraseñas fallan ante el phishing en tiempo real
En 2025, la operación de robo de credenciales más efectiva de internet no fue una filtración de datos. Fue un kit de phishing vendido como servicio, operando a escala: proxies de hombre en el medio (attacker-in-the-middle, AiTM) que se interponían entre la víctima y una página de login clonada, reenviando cada pulsación de tecla — contraseña, código TOTP, notificación push — al sitio real en tiempo real. Una víctima que tecleaba su contraseña y su código de seis dígitos en un clon convincente era conectada por el atacante segundos después. El 2FA tradicional no la salvó, porque el atacante no reutilizaba códigos robados: estaba retransmitiendo códigos en vivo.
Por eso la respuesta de la industria no es un secreto más fuerte. Es la autenticación resistente al phishing (phishing-resistant authentication): credenciales vinculadas criptográficamente a tu origen, de modo que un clon perfecto de tu página de login no solo sea difícil de detectar — es estructuralmente incapaz de recibir la credencial. WebAuthn, y las passkeys construidas sobre él, son esa respuesta. Están soportadas en todos los navegadores importantes, en iOS, Android, Windows, macOS y Linux, y eliminan por completo las contraseñas, los OTP y el SMS de tu flujo de autenticación.
Si tienes un producto en Node.js o Next.js, las passkeys ya no son una característica experimental — son la línea base esperada para un login que se tome en serio la seguridad. Este artículo cubre cómo funciona el protocolo, cómo implementar el registro y la autenticación con SimpleWebAuthn en una app Next.js App Router, y los errores que rompen los despliegues en producción.
Qué es realmente una passkey
Una passkey es una credencial de clave pública (public-key credential) creada en el dispositivo del usuario por un autenticador (authenticator) WebAuthn — un autenticador de plataforma como Face ID, Windows Hello o el sensor de huellas del dispositivo, o un autenticador portátil (roaming authenticator) como una YubiKey.
La idea clave: durante el registro, el autenticador genera un par de claves (key pair). La clave privada (private key) nunca sale del autenticador — no puede extraerse, copiarse ni exportarse, ni siquiera por el usuario. La clave pública (public key) se envía a tu servidor y se almacena. La autenticación funciona entonces por desafío-respuesta: tu servidor envía un desafío (challenge) aleatorio, el autenticador lo firma con la clave privada, y tu servidor verifica la firma con la clave pública almacenada.
De este diseño se derivan tres propiedades:
- Sin secretos compartidos. No hay contraseña, código ni token que un atacante pueda robar de una base de datos o interceptar en tránsito. La clave privada existe en un único lugar y nunca se mueve.
- Vinculación al origen. La credencial está limitada a un identificador de parte fiadora (relying party ID, RP ID) — tu dominio. El navegador solo libera una firma para el origen exacto que coincide con el RP ID. Un clon en
acme-login.attacker.comno puede activar el autenticador, porque el RP ID no coincide. Esta es la propiedad que mata el phishing AiTM. - Verificación y presencia del usuario. El autenticador exige un gesto local — biométrico, PIN o como mínimo un toque — antes de firmar. Un token de sesión robado o un atacante remoto no pueden producir ese gesto.
Las passkeys son la forma productizada de WebAuthn: credenciales descubribles (discoverable credentials, también llamadas claves residentes) sincronizadas entre los dispositivos del usuario, de modo que el botón "crear una passkey" sustituye a "crear una contraseña" sin preguntas de seguridad, sin gestores de contraseñas y sin registro de OTP.
Las dos ceremonias
WebAuthn tiene dos flujos, y ambos son de dos pasos (opciones → verificación):
Registro (crear la credencial): tu servidor genera las opciones de registro con el RP ID, un desafío aleatorio e información del usuario; el navigator.credentials.create() del navegador invoca al autenticador, que produce un nuevo par de claves y devuelve una respuesta de atestación (attestation); tu servidor verifica la respuesta — desafío, origen, RP ID y firma — y almacena la clave pública.
Autenticación (probar la posesión): tu servidor genera las opciones de autenticación con un desafío nuevo; el navigator.credentials.get() del navegador pide al autenticador que firme el desafío con la clave privada correspondiente; tu servidor verifica la firma y los datos del cliente (client data) firmados, incluidos el origen y el hash del desafío, y actualiza el contador de la credencial.
Ambos flujos tienen límite de tiempo: los desafíos deben ser de un solo uso y de vida corta, o todo el esquema degenera en un ataque de repetición (replay attack). Los implementaremos con esa restricción incorporada.
Cómo construir autenticación sin contraseña en Next.js con SimpleWebAuthn
SimpleWebAuthn es el estándar de facto para WebAuthn en Node.js — gestiona el engorroso parseo de CBOR, COSE y atestación para que no tengas que hacerlo tú. Esta guía usa @simplewebauthn/server y @simplewebauthn/browser (v11+), Prisma y route handlers de Next.js App Router. Los mismos patrones se trasladan a Express, Fastify o cualquier framework de Node.js.
1. Dependencias y base de datos
pnpm add @simplewebauthn/server @simplewebauthn/browser jose
Necesitas dos modelos: el usuario y las credenciales que le pertenecen.
model User {
id String @id @default(cuid())
email String @unique
name String?
credentials PasskeyCredential[]
}
model PasskeyCredential {
id String @id // base64url credential ID, generated by the authenticator
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
publicKey String // base64url COSE public key
counter Int @default(0)
transports String[] @default([])
createdAt DateTime @default(now())
lastUsedAt DateTime?
@@index([userId])
}
2. Almacenar el desafío (de un solo uso, firmado)
El desafío es el mecanismo anti-repetición, así que debe almacenarse donde el cliente no pueda manipularlo y consumirse exactamente una vez. Una cookie firmada, de vida corta y httpOnly es un patrón simple y sin estado:
// lib/webauthn-challenge.ts
import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.CHALLENGE_SECRET);
export async function saveChallenge(challenge: string) {
const token = await new SignJWT({ challenge })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("5m")
.sign(secret);
(await cookies()).set("wa_challenge", token, {
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 300,
path: "/",
});
}
export async function consumeChallenge(): Promise<string | null> {
const jar = await cookies();
const token = jar.get("wa_challenge")?.value;
jar.delete("wa_challenge"); // single-use: consume on first read
if (!token) return null;
try {
const { payload } = await jwtVerify(token, secret);
return payload.challenge as string;
} catch {
return null; // expired, tampered, or already used
}
}
Fíjate en la cookie con sameSite: "strict" y en los endpoints solo-POST: juntos impiden que los endpoints de desafío se conviertan en un objetivo de CSRF.
3. Registro: endpoint de opciones
// app/api/webauthn/register/options/route.ts
import { generateRegistrationOptions, isoUint8Array } from "@simplewebauthn/server";
import { saveChallenge } from "@/lib/webauthn-challenge";
import { prisma } from "@/lib/prisma";
const rpID = "acme.com"; // effective domain — no scheme, no port
const rpName = "Acme Corp";
const origin = "https://acme.com";
export async function POST(req: Request) {
const { email, name } = await req.json();
const user = await prisma.user.upsert({
where: { email },
update: {},
create: { email, name },
});
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.email,
userDisplayName: user.name ?? user.email,
userID: isoUint8Array.fromUTF8String(user.id),
attestationType: "none", // we only need the public key, not device attestation
authenticatorSelection: {
residentKey: "preferred", // allow passkeys (discoverable credentials)
userVerification: "preferred",
},
excludeCredentials: user.credentials.map((c) => ({
id: c.id, // base64url — v9+ expects strings, not Buffers
type: "public-key",
})),
timeout: 60_000,
});
await saveChallenge(options.challenge);
return Response.json({ options });
}
excludeCredentials impide que el usuario registre el mismo autenticador dos veces — un paso que la gente se salta copiando y pegando, y luego se pregunta por qué un segundo "registro" tiene éxito con el mismo dispositivo.
4. Registro: endpoint de verificación
// app/api/webauthn/register/verify/route.ts
import { verifyRegistrationResponse, isoBase64URL } from "@simplewebauthn/server";
import { consumeChallenge } from "@/lib/webauthn-challenge";
const rpID = "acme.com";
const origin = "https://acme.com";
export async function POST(req: Request) {
const { email, response } = await req.json();
const expectedChallenge = await consumeChallenge();
if (!expectedChallenge) {
return Response.json({ error: "Challenge missing, expired, or reused" }, { status: 400 });
}
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: [origin], // allowlist, not a single string
expectedRPID: rpID,
});
if (!verification.verified) {
return Response.json({ error: "Registration verification failed" }, { status: 400 });
}
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo!;
const user = await prisma.user.findUnique({ where: { email } });
await prisma.passkeyCredential.create({
data: {
id: credential.id,
userId: user!.id,
publicKey: isoBase64URL.fromBuffer(credential.publicKey),
counter: credential.counter,
},
});
return Response.json({ ok: true });
}
Guarda credentialBackedUp si te importan las garantías de recuperación (una credencial sin copia de seguridad se pierde cuando se pierde el dispositivo), y anota credentialDeviceType para analítica.
5. Autenticación: opciones + verificación
// app/api/webauthn/login/options/route.ts
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { saveChallenge } from "@/lib/webauthn-challenge";
const rpID = "acme.com";
export async function POST(req: Request) {
const { email } = await req.json();
const user = await prisma.user.findUnique({ where: { email }, include: { credentials: true } });
const options = await generateAuthenticationOptions({
rpID,
userVerification: "preferred",
// Omit allowCredentials for discoverable credentials: the authenticator
// picks the right key itself, which is what enables conditional UI.
allowCredentials: user
? user.credentials.map((c) => ({ id: c.id, type: "public-key" as const }))
: [],
timeout: 60_000,
});
await saveChallenge(options.challenge);
return Response.json({ options });
}
El endpoint de verificación es donde vive la comprobación del contador:
// app/api/webauthn/login/verify/route.ts
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { consumeChallenge } from "@/lib/webauthn-challenge";
import { createSession } from "@/lib/session";
const rpID = "acme.com";
const origin = "https://acme.com";
export async function POST(req: Request) {
const { email, response } = await req.json();
const expectedChallenge = await consumeChallenge();
if (!expectedChallenge) {
return Response.json({ error: "Challenge missing, expired, or reused" }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { email },
include: { credentials: true },
});
const credential = user?.credentials.find((c) => c.id === response.id);
if (!user || !credential) {
return Response.json({ error: "Unknown credential" }, { status: 400 });
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: [origin],
expectedRPID: rpID,
credential: {
id: credential.id,
publicKey: isoBase64URL.toBuffer(credential.publicKey),
counter: credential.counter,
transports: credential.transports as AuthenticatorTransport[],
},
});
const { authenticationInfo } = verification;
if (!verification.verified) {
return Response.json({ error: "Authentication failed" }, { status: 400 });
}
// Cloning detection: a real authenticator increments its counter on every
// signature. A copied key signs with a stale counter.
if (authenticationInfo.newCounter > 0 && authenticationInfo.newCounter <= credential.counter) {
await prisma.passkeyCredential.delete({ where: { id: credential.id } });
// TODO: alert your security team — possible cloned authenticator
return Response.json({ error: "Credential revoked" }, { status: 403 });
}
await prisma.passkeyCredential.update({
where: { id: credential.id },
data: { counter: authenticationInfo.newCounter, lastUsedAt: new Date() },
});
await createSession(user.id); // httpOnly, Secure, SameSite cookie — the actual login
return Response.json({ ok: true });
}
6. El componente de cliente
"use client";
import { useState } from "react";
import {
startRegistration,
startAuthentication,
browserSupportsWebAuthn,
platformAuthenticatorIsAvailable,
} from "@simplewebauthn/browser";
export function PasskeyAuth({ mode }: { mode: "register" | "login" }) {
const [error, setError] = useState("");
if (!browserSupportsWebAuthn()) {
return <p>WebAuthn is not supported in this browser.</p>;
}
async function handleClick() {
try {
const endpoint = mode === "register" ? "register" : "login";
const optionsRes = await fetch(`/api/webauthn/${endpoint}/options`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "user@acme.com" }),
});
const { options } = await optionsRes.json();
const credential =
mode === "register"
? await startRegistration({ optionsJSON: options })
: await startAuthentication({ optionsJSON: options });
const verifyRes = await fetch(`/api/webauthn/${endpoint}/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "user@acme.com", response: credential }),
});
if (!verifyRes.ok) {
const body = await verifyRes.json();
throw new Error(body.error ?? "Verification failed");
}
window.location.href = mode === "register" ? "/dashboard" : "/dashboard";
} catch (err) {
setError((err as Error).message);
}
}
return (
<div>
<button onClick={handleClick} disabled={!browserSupportsWebAuthn()}>
{mode === "register" ? "Create a passkey" : "Sign in with a passkey"}
</button>
{error && <p role="alert">{error}</p>}
</div>
);
}
La interfaz condicional (conditional UI, autofill) es la mejora que hace que las passkeys se sientan nativas: con mediation: "conditional" dentro de startAuthentication, el navegador ofrece la passkey a través del desplegable nativo de autocompletado — sin botón, sin redirección y sin campo de contraseña:
useEffect(() => {
if (!browserSupportsWebAuthn()) return;
platformAuthenticatorIsAvailable().then((available) => {
if (!available) return;
fetch("/api/webauthn/login/options", { method: "POST" })
.then((r) => r.json())
.then(async ({ options }) => {
const credential = await startAuthentication({
optionsJSON: options,
mediation: "conditional",
});
// ...verify exactly as in handleClick above
})
.catch(() => {/* user dismissed the prompt */});
});
}, []);
El input acompañante necesita la pista mágica de autocompletado:
<input
name="passkey"
autoComplete="username-webauthn"
placeholder="Sign in with a passkey"
/>
El contador: detectar autenticadores clonados
El counter es el detector de clonación de WebAuthn, y la mayoría de las implementaciones lo ignoran. Cada autenticador mantiene un contador monótono que se incrementa en cada aserción. Un dispositivo legítimo siempre produce un contador mayor que el último que viste. Si alguna vez recibes una aserción con un contador igual o inferior al almacenado (y el autenticador soporta contadores), la clave privada existe en dos lugares — alguien la copió, lo que solo es posible extrayéndola de un autenticador comprometido o de su copia de seguridad.
La comprobación son tres líneas, mostradas arriba: rechaza newCounter <= storedCounter (tratando 0 como "no soportado" y saltándolo), revoca la credencial y avisa a tu equipo de seguridad. Es la única señal que WebAuthn te da de que una credencial ha sido clonada, así que conéctala a tus alertas desde el primer día — no después de un incidente.
Recuperación y sincronización: las contrapartidas honestas
Las passkeys eliminan el eslabón más débil de la autenticación, pero introducen dos realidades operativas para las que debes diseñar antes del despliegue:
1. No hay contraseña que restablecer. Si un usuario pierde su último autenticador — teléfono borrado, portátil perdido, sin sincronización — se queda fuera, y tú no puedes arreglarlo, porque nunca tuviste el secreto. Entrega códigos de recuperación (recovery codes) en el alta (generados una vez, mostrados una vez, almacenados con hash en el servidor, mismo patrón que los códigos de respaldo), y considera un fallback de TOTP para la ruta de recuperación. Sé honesto contigo mismo: un fallback de TOTP reintroduce un vector de phishing para la ruta de recuperación solamente — por eso los códigos de recuperación son el mejor default.
2. Las passkeys sincronizadas trasladan la confianza a la cuenta en la nube. Las passkeys de plataforma se sincronizan a través de Llavero de iCloud (iCloud Keychain), Google Password Manager y gestores de terceros como 1Password. Eso es una victoria enorme de UX — la passkey sobrevive a un teléfono perdido — pero significa que la credencial está tan protegida como la cuenta de Apple ID o de Google del usuario. Un atacante que comprometa esa cuenta consigue la passkey sincronizada. Sigue siendo estrictamente mejor que una contraseña, pero para cuentas privilegiadas — administradores, CI/CD, consolas en la nube, tus usuarios root — exige una llave de seguridad de hardware (no sincronizada, no exportable) con residentKey: "discouraged" o fuerza credenciales vinculadas al dispositivo. Segmenta el riesgo: passkeys de conveniencia para empleados, llaves de hardware para el acceso de ruptura de cristal (break-glass).
Errores que rompen los despliegues en producción
- El
rpIDes un dominio, no una URL. Sin esquema, sin puerto, sin ruta. Debe ser un sufijo registrable del origen —acme.comfunciona parahttps://app.acme.com;app.acme.comsolo funciona para ese subdominio exacto. Cambiar elrpIDmás adelante invalida todas las credenciales existentes, así que establécelo una vez y trátalo como permanente. - Deriva en la lista blanca de orígenes.
expectedOrigindebe ser la lista exacta de orígenes — con el esquema. Si tu app se sirve desde varios dominios (app, staging, un dominio de un partner), enuméralos explícitamente y centraliza la constante; una verificación que fija un único origen rompe staging y acepta silenciosamente uno nuevo si lo amplías en un solo sitio. localhostes especial. WebAuthn requiere contextos seguros;http://localhostse trata como contexto seguro en Chrome y Firefox (Safari históricamente menos). Tu entorno de revisión de staging en una IP de LAN no funcionará por HTTP plano — necesitas HTTPS o un dominio real. Prueba con una URL HTTPS tunelizada, no con una IP pelada.- Reutilizar desafíos. Si tus endpoints de opciones y verificación no consumen el desafío de forma atómica, un atacante puede repetir una aserción interceptada. De un solo uso, firmado, TTL de 5 minutos — innegociable.
- Verificación en el cliente. Algunos tutoriales comprueban
verification.verifieden el navegador. La salida del navegador está controlada por el atacante; toda comprobación pertenece al servidor. - Contador sin persistir. Si nunca almacenas y comparas
newCounter, pierdes por completo la detección de clonación. - Sin política de
userVerification. Decide entrerequiredypreferredy hazla cumplir en la verificación (requireUserVerification: truepara flujos de alta seguridad). Si la UI dice "se requiere biométrico" pero el servidor acepta un toque silencioso, tu política es ficción. - Passkeys en iframes. WebAuthn no está disponible en iframes de origen cruzado — un widget embebido no puede registrar una passkey para tu dominio. Diseña para navegación de nivel superior.
- No excluir credenciales existentes en el registro. Sin
excludeCredentials, el mismo autenticador se registra dos veces, y ahora tienes dos credenciales vivas que en realidad son una — rastros de auditoría confusos y revocación rota.
La checklist para el CTO
- [ ] Los flujos de registro y autenticación usan solo verificación en servidor; el desafío es de un solo uso, firmado y con TTL corto.
- [ ]
expectedOriginyexpectedRPIDson constantes centralizadas; elrpIDestá congelado y documentado. - [ ] Los contadores de credenciales se almacenan y comprueban en cada aserción; la detección de clonación avisa a seguridad al superar un umbral.
- [ ] Códigos de recuperación emitidos en el alta; el fallback de TOTP (si existe) está explícitamente limitado a la recuperación, no al login diario.
- [ ] Las cuentas privilegiadas (administradores, CI/CD, consolas en la nube) exigen credenciales vinculadas a hardware; las cuentas de empleados pueden usar passkeys sincronizadas.
- [ ] El rate limiting del login sigue aplicándose — las passkeys matan el phishing, no el relleno de credenciales (credential stuffing) de cualquier ruta de contraseña restante (ver nuestra guía de rate limiting de API).
- [ ] Las cookies de sesión tras el login con passkey son httpOnly, Secure, SameSite y de vida corta; el logout revoca el estado en servidor.
- [ ] Matriz de soporte de navegadores documentada (iOS Safari, Android Chrome, Windows Hello, macOS Touch ID) y probada con dispositivos reales antes del GA.
Conclusión
Las passkeys son el primer primitivo de autenticación que hace que el phishing sea estructuralmente imposible en lugar de meramente más difícil: no hay secreto compartido que robar, y la credencial está vinculada a tu origen, de modo que un clon de tu página de login ni siquiera puede solicitar una firma. El coste de implementación en una app Next.js son unos pocos route handlers y un componente de cliente — SimpleWebAuthn absorbe la complejidad del protocolo — pero las propiedades de seguridad vienen de los detalles: desafíos de un solo uso, listas blancas de orígenes estrictas, contadores persistidos y una historia de recuperación honesta.
Empieza con un solo flujo — registro sin contraseña para usuarios nuevos, manteniendo los logins con contraseña existentes — mide la conversión y expande. Cada credencial que saques de las contraseñas es una credencial menos que un kit de phishing AiTM puede retransmitir.
¿Necesitas una revisión profesional de tu despliegue de WebAuthn? Programa una auditoría de seguridad — probaremos tus flujos de registro y aserción, el manejo de desafíos, la lógica de contadores y las rutas de recuperación contra escenarios de ataque reales.
La semana que viene: WebSocket Security — protección de APIs en tiempo real en Node.js. Desde comprobaciones de origen y revalidación de tokens en el upgrade hasta rate limiting de mensajes y el cierre del agujero del secuestro de WebSocket entre sitios (CSWSH).
JS Security Audit
Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.