Saltar al contenido
ecdsa.com
← Todos los artículos

Passkeys para desarrolladores: WebAuthn sin conjeturas

Una llave de acceso (passkey) es una credencial de WebAuthn con la que el usuario puede iniciar sesión sin escribir un nombre de usuario ni una contraseña. La API del navegador es pequeña; las decisiones que la rodean, no. Aquí están las dos ceremonias de principio a fin, lo que debe comprobar el servidor y los errores exactos que lanza Chromium, todo a partir de una ejecución con un autenticador virtual.

Respuesta breve. Una llave de acceso es una credencial detectable de WebAuthn: un par de claves que crea un autenticador (un teléfono, el hardware seguro de un equipo portátil, un gestor de contraseñas o una llave de seguridad) para un solo sitio, la relying party, es decir, la parte que confía en la autenticación. En el registro, el servidor recibe una clave pública; en el inicio de sesión, una firma sobre un desafío que generó el servidor. El servidor debe comprobar el desafío, el origen, el RP ID y el indicador de verificación del usuario en cada respuesta. Pide residentKey: "required" y userVerification: "required", ofrece -8, -7 y -257 como algoritmos, deja la atestación en "none" salvo que seas una empresa que restringe los modelos de autenticador, y cuenta con que la mayoría de las llaves de acceso estén sincronizadas entre dispositivos.

El modelo en una tabla

TérminoQué significa para tu código
Relying party (RP) y RP IDTu sitio. El RP ID es un dominio, como example.com; las credenciales funcionan en ese dominio y en sus subdominios, nunca en otro lugar
Credencial detectableSe guarda en el autenticador junto con el identificador de usuario (user handle), así que el inicio de sesión no necesita nombre de usuario. WebAuthn Level 3 recoge «passkey» como otro nombre para ella
Identificador de usuario (user handle, user.id)El ID opaco de la cuenta en tu sistema, de 64 bytes como máximo, que se devuelve al iniciar sesión. Nunca una dirección de correo electrónico
Presencia del usuario (UP) y verificación del usuario (UV)UP: alguien toca el autenticador o confirma. UV: el autenticador comprueba un PIN o un dato biométrico. Son indicadores de los datos del autenticador
Apta para copia de seguridad (BE) y con copia de seguridad (BS)Una credencial que puede sincronizarse, o ya se sincroniza, con otros dispositivos: una llave de acceso sincronizada, o «credencial multidispositivo» (multi-device credential) en la especificación
Atestación y AAGUIDUna cadena de certificados opcional que indica qué modelo de autenticador creó la clave; el AAGUID identifica el modelo

Los indicadores de los datos del autenticador son el bit 0 UP, el bit 2 UV, el bit 3 BE, el bit 4 BS, el bit 6 AT (incluye datos de credencial atestados) y el bit 7 ED (extensiones) (WebAuthn Level 3 §6.1). WebAuthn Level 3 pasó a ser recomendación del W3C el 25 de agosto de 2026.

Servidor: las dos ceremonias

Esta es una relying party completa con @simplewebauthn/server 14.0.3. El almacenamiento es en memoria para que el ejemplo sea breve; en producción, los tres mapas son tablas de una base de datos y el desafío se guarda en la sesión del usuario, del lado del servidor.

server.mjs (@simplewebauthn/server 14.0.3)
// Relying party: registration and sign-in ceremonies with @simplewebauthn/server 14.0.3.
import {
  generateAuthenticationOptions,
  generateRegistrationOptions,
  verifyAuthenticationResponse,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";

const rpID = "localhost"; // the site's registrable domain in production, e.g. "example.com"
const origin = "http://localhost:3000"; // every origin allowed to use this RP ID
const users = new Map(); // userName -> { id, credentials: [] }
const credentials = new Map(); // credential ID -> { userName, publicKey, counter, transports }
const pending = new Map(); // ceremony ID -> expected challenge, single use

export async function registrationOptions(userName, { attestation = "none" } = {}) {
  const user = users.get(userName) ?? { id: crypto.getRandomValues(new Uint8Array(16)), credentials: [] };
  users.set(userName, user);
  const options = await generateRegistrationOptions({
    rpName: "Example",
    rpID,
    userName,
    userID: user.id,
    attestationType: attestation,
    authenticatorSelection: { residentKey: "required", userVerification: "required" },
    supportedAlgorithmIDs: [-8, -7, -257], // EdDSA, ES256, RS256
    excludeCredentials: user.credentials.map((id) => ({ id, transports: credentials.get(id).transports })),
  });
  pending.set(`reg:${userName}`, options.challenge);
  return options;
}

export async function finishRegistration(userName, response) {
  const expectedChallenge = pending.get(`reg:${userName}`);
  pending.delete(`reg:${userName}`);
  const { verified, registrationInfo: info } = await verifyRegistrationResponse({
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    requireUserVerification: true,
  });
  if (!verified) throw new Error("registration not verified");
  const { credential } = info;
  credentials.set(credential.id, { userName, publicKey: credential.publicKey, counter: credential.counter, transports: credential.transports });
  users.get(userName).credentials.push(credential.id);
  return info;
}

export async function authenticationOptions() {
  // Empty allowCredentials: the authenticator offers its discoverable credentials (passkeys).
  const options = await generateAuthenticationOptions({ rpID, userVerification: "required" });
  pending.set(`auth:${options.challenge}`, options.challenge);
  return options;
}

export async function finishAuthentication(response, challenge) {
  if (!pending.delete(`auth:${challenge}`)) throw new Error("unknown or used challenge");
  const stored = credentials.get(response.id);
  if (!stored) throw new Error("unknown credential");
  const { verified, authenticationInfo } = await verifyAuthenticationResponse({
    response,
    expectedChallenge: challenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: { id: response.id, publicKey: stored.publicKey, counter: stored.counter, transports: stored.transports },
    requireUserVerification: true,
  });
  if (!verified) throw new Error("authentication not verified");
  stored.counter = authenticationInfo.newCounter;
  return { userName: stored.userName, ...authenticationInfo };
}

Qué comprueban las llamadas de verificación y por qué importa cada comprobación:

  • Desafío. Aleatorio, de un solo uso y vinculado a esta ceremonia. Una respuesta con un desafío antiguo es una repetición (replay).
  • Origen. El navegador escribe el origen de la página en clientDataJSON, y el autenticador firma un hash de esos datos. Compararlo con tu lista de orígenes es lo que hace que las llaves de acceso resistan el phishing: un sitio que imita al real produce una respuesta con su propio origen.
  • Hash del RP ID. Los datos del autenticador empiezan con el SHA-256 del RP ID; una credencial de otro sitio no sirve para el tuyo.
  • Indicadores. UP debe estar activado; UV debe estarlo cuando confías solo en la llave de acceso, que es precisamente su razón de ser.
  • Firma sobre los datos del autenticador y el hash de los datos del cliente, con la clave pública guardada en el registro.
  • Contador. Muchas llaves de acceso sincronizadas devuelven siempre 0. WebAuthn considera que un contador que disminuye indica un posible clon solo cuando interviene un valor distinto de cero (§6.1.1); nunca rechaces el cero.

Navegador: dos llamadas

browser.js
// Browser side of both ceremonies. JSON options from the server need their base64url
// fields turned into ArrayBuffers; the static helpers on PublicKeyCredential do that.
async function register(optionsJSON) {
  const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON);
  const credential = await navigator.credentials.create({ publicKey });
  return credential.toJSON(); // send to the server as-is
}

async function signIn(optionsJSON) {
  const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
  const credential = await navigator.credentials.get({ publicKey });
  return credential.toJSON();
}

parseCreationOptionsFromJSON, parseRequestOptionsFromJSON y toJSON forman parte de WebAuthn Level 3 y sustituyen el código de conversión base64url que antes arrastraba cada proyecto. Para que un campo de nombre de usuario ofrezca llaves de acceso en el autocompletado del navegador, llama a navigator.credentials.get() con mediation: "conditional" al cargar la página, después de comprobar PublicKeyCredential.isConditionalMediationAvailable(), y marca ese campo de entrada con autocomplete="username webauthn".

Ejecución de principio a fin

En el laboratorio, Playwright 1.64.1 (mcr.microsoft.com/playwright:v1.64.1-noble) controló Chromium, con un autenticador virtual añadido mediante el Chrome DevTools Protocol. La página en http://localhost:3000 ejecutó browser.js; el script de control introducía en ella las opciones de server.mjs y sacaba las respuestas. Cada línea procede de un registro o de un inicio de sesión reales:

Salida (Chromium 156.0.8078.4, Node.js en la imagen de Playwright)
chromium 156.0.8078.4, virtual platform authenticator with user verification
registration options: challenge 43 chars, pubKeyCredParams -8, -7, -257, attestation none
registered: fmt none, COSE alg -8, transports internal, credentialDeviceType singleDevice, backedUp false, userVerified true, aaguid 01020304-0506-0708-0102-030405060708
second registration on the same authenticator: InvalidStateError: The user attempted to register an authenticator that contains one of the credentials already registered with the relying party.
signed in as alice: userVerified true, newCounter 2, credentialBackedUp false
replayed assertion: unknown or used challenge
response created on http://localhost:4000: Unexpected registration response origin "http://localhost:4000", expected "http://localhost:3000"
synced passkey, attestation "direct": fmt packed, credentialDeviceType multiDevice, backedUp true
sign-in when the authenticator cannot verify the user: NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.

Qué demuestra cada línea:

  • Algoritmos. Las opciones ofrecían -8, -7 y -257; el autenticador eligió -8 (EdDSA), el primero que admite. El orden expresa la preferencia.
  • excludeCredentials funciona, con el InvalidStateError que le dice a tu interfaz «este dispositivo ya tiene una llave de acceso para esta cuenta».
  • Inicio de sesión sin nombre de usuario. allowCredentials estaba vacío; el autenticador ofreció su credencial detectable y el servidor encontró la cuenta a partir del ID de credencial y de la clave pública guardada para esa credencial.
  • Las repeticiones fallan en el desafío. Borrar el desafío en cuanto se usa es lo que hace fallar el segundo intento.
  • El navegador no bloquea todos los orígenes incorrectos. Una página en http://localhost:4000 pudo crear una credencial para el RP ID localhost, porque el RP ID abarca todos los puertos y subdominios de ese dominio. Solo la comprobación de origen del servidor la rechazó: Unexpected registration response origin. Mantén expectedOrigin como una lista exacta.
  • Las llaves de acceso sincronizadas se identifican como tales. Con la aptitud para copia de seguridad (BE) activada, SimpleWebAuthn informó de multiDevice y backedUp true.
  • El navegador hace cumplir la UV obligatoria. Con un autenticador que no podía verificar al usuario, el navegador rechazó la operación con el error genérico NotAllowedError; el requireUserVerification del lado del servidor es la segunda línea de defensa.

Despliegue de las llaves de acceso junto a las contraseñas

Pocos sitios pueden migrar todas las cuentas a la vez. WebAuthn Level 3 añade las piezas para un despliegue gradual, y todas se pueden detectar mediante PublicKeyCredential.getClientCapabilities():

  • Ofrece una llave de acceso justo después de un inicio de sesión con contraseña. Con la capacidad conditionalCreate, navigator.credentials.create() con mediation: "conditional" permite que el gestor de contraseñas que acaba de rellenar la contraseña cree una llave de acceso sin un diálogo adicional.
  • Permite iniciar sesión desde el campo de nombre de usuario. La capacidad conditionalGet es el flujo de autocompletado descrito más arriba; los usuarios con una llave de acceso la eligen y los demás escriben una contraseña como antes.
  • Mantén la lista del autenticador sincronizada con la tuya. Cuando un usuario borra una llave de acceso en la configuración de su cuenta en tu sitio, el autenticador la sigue mostrando. signalUnknownCredential() le indica que un ID de credencial ya no se acepta, signalAllAcceptedCredentials() envía la lista completa de un usuario con la sesión iniciada y signalCurrentUserDetails() actualiza el nombre mostrado después de que un usuario cambie el nombre de su cuenta (§5.1.10).
  • Varios dominios, un solo RP ID. Una marca que ofrece inicio de sesión en example.com y en example.co.uk puede mantener un solo RP ID y enumerar los demás orígenes en https://example.com/.well-known/webauthn como {"origins": [...]}. El navegador descarga ese archivo cuando el RP ID no es el dominio de la propia página (solicitudes de orígenes relacionados o related origin requests, §5.11); si la descarga falla, el resultado es el SecurityError que se muestra más abajo.
  • Guarda BE y BS de cada credencial. Un usuario cuya única llave de acceso es de un solo dispositivo y no tiene copia de seguridad perderá el acceso si pierde ese dispositivo; pídele que añada una segunda antes de retirarle la contraseña.

Atestación: cuándo la necesitas

La atestación responde a la pregunta «¿qué modelo de autenticador creó esta clave?». No dice nada del usuario, del nivel de parches del dispositivo ni de si tiene acceso root. Con attestation: "none", el valor predeterminado, el navegador sustituye cualquier declaración de atestación por una vacía antes de que la vea tu servidor. Aun así, el laboratorio recibió el AAGUID del autenticador: WebAuthn Level 2 pedía a los navegadores que en este caso lo sustituyeran por 16 bytes con valor cero, y Level 3 eliminó ese paso. Sin atestación, el AAGUID es una afirmación, no una prueba.

No exijas atestación en un sitio para el público general. Las llaves de acceso sincronizadas están pensadas para acompañar al usuario a cualquier dispositivo, y sus proveedores, por lo general, no las han atestado: cuando Google lanzó las llaves de acceso en Android, dijo que «inicialmente no tendrán ninguna atestación» (lista fido-dev, 2022). Al pedirle "direct", el autenticador virtual del laboratorio devolvió una declaración packed, lo que muestra qué envía un autenticador que hace atestación, pero no qué hace un proveedor de sincronización concreto; así que prueba con los proveedores que usan de verdad tus usuarios. Solicítala cuando debas restringir el registro a un conjunto de modelos, como las llaves de seguridad que entrega tu empresa, y verifica la cadena con el servicio de metadatos de FIDO (FIDO Metadata Service). Para mostrar a los usuarios un nombre comprensible de su proveedor de llaves de acceso («Google Password Manager», «1Password»), la lista de AAGUID de autenticadores de llaves de acceso, mantenida por la comunidad, asocia los AAGUID a nombres; su propio README dice que solo sirve para mostrarlos y que no es una fuente para decisiones de seguridad.

Algoritmos COSE

pubKeyCredParams enumera identificadores de algoritmos COSE (registro COSE de la IANA). El propio ejemplo de la especificación acepta EdDSA, ES256 y RS256 y prefiere EdDSA; esa lista cubre prácticamente todos los autenticadores en uso:

ValorNombreNotas
-8EdDSASolo Ed25519 en WebAuthn (§5.8.5); RFC 9864 lo marcó como obsoleto en el registro COSE en favor de -19 Ed25519, pero los autenticadores siguen emitiendo -8
-7ES256ECDSA P-256; el más ampliamente admitido. Obsoleto en COSE en favor de -9 ESP256, en la misma situación
-257RS256RSA PKCS#1 v1.5; Windows Hello en Windows 10 y en algunas instalaciones de Windows 11 solo crea credenciales RS256 (Yubico en la lista del W3C)
-48, -49, -50ML-DSA-44, -65, -87Registrados por RFC 9964 (mayo de 2026). El autenticador virtual de Chromium 156 aceptó -49 (más abajo); comprueba los autenticadores reales y tu biblioteca de servidor antes de depender de ellos

Mantén -7 y -257 en la lista aunque prefieras -8: cuando pubKeyCredParams está vacío, el valor predeterminado de la propia especificación es ES256 y RS256. Guarda el algoritmo junto con la credencial; SimpleWebAuthn lo conserva dentro de la clave pública COSE guardada.

Los errores DOMException

navigator.credentials.create() y get() notifican los fallos mediante un pequeño conjunto de excepciones, enumeradas en WebAuthn Level 3 §5.1.3.1 y §5.1.4.3. Esto es lo que lanzó Chromium 156 ante errores habituales, con una llave de seguridad USB virtual sin almacenamiento de credenciales detectables:

Salida: errors.mjs (Chromium 156.0.8078.4)
only an unknown algorithm (-65000)           NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
only ML-DSA-65 (-49)                         created, COSE alg -49
residentKey required, key cannot store it    NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
rp.id "example.com" on localhost             SecurityError: The relying party ID is not a registrable domain suffix of, nor equal to the current domain. Subsequently, an attempt to fetch the .well-known/webauthn resource of the claimed RP ID failed.
user.id of 65 bytes                          TypeError: User handle exceeds 64 bytes.
aborted with AbortController                 AbortError: signal is aborted without reason
ExcepciónSignificado según la especificaciónQué hacer
NotAllowedErrorExcepción genérica: cancelación del usuario, tiempo de espera agotado, ninguna credencial coincidente, requisitos que ningún autenticador cumpleTrátala como «no se completó»; ofrece reintentar y otro método. Nunca muestres el mensaje tal cual
InvalidStateErrorSe reconoció una entrada de excludeCredentialsIndica al usuario que este dispositivo ya tiene una llave de acceso para este sitio
SecurityErrorEl RP ID no es el dominio de la página ni un sufijo registrable de él, o falló la validación de orígenes relacionadosCorrige la configuración; consulta la página sobre SecurityError
NotSupportedErrorNingún algoritmo utilizable en pubKeyCredParamsOfrece -7 y -257
ConstraintErrorresidentKey o userVerification obligatorios y ningún autenticador capaz de cumplirlosEn la práctica, se trata igual que NotAllowedError
AbortErrorTu AbortController canceló la llamadaEs lo esperado al pasar del autocompletado a un diálogo modal
TypeErrorOpciones mal formadas, por ejemplo un identificador de usuario de más de 64 bytesError en tus opciones

Dos de los resultados del laboratorio difieren de la lista de la especificación: Chromium respondió NotAllowedError, y no NotSupportedError, cuando el único algoritmo era desconocido, y NotAllowedError, y no ConstraintError, cuando se exigía una credencial detectable a una llave de seguridad que no puede guardarla. El código que se basa en NotSupportedError o ConstraintError para explicar el problema a los usuarios nunca recibirá esas excepciones de Chromium en estos casos. Ambas API solo existen en contextos seguros. En una página servida por HTTP sin cifrar que no sea localhost, Chromium 156 informó isSecureContext: false, con navigator.credentials, PublicKeyCredential y crypto.subtle todos undefined, así que el código falla con un TypeError antes de que empiece cualquier ceremonia (la variante de Web Crypto es crypto.subtle is undefined). Sirve por HTTPS todas las páginas que usan llaves de acceso; http://localhost es la única excepción, para desarrollo.

Lista de comprobación

  1. RP ID configurado con el dominio registrable que ven tus usuarios; expectedOrigin, una lista explícita de orígenes HTTPS que incluya todos los subdominios en los que se inicia sesión.
  2. residentKey: "required", userVerification: "required" y el servidor comprueba UV en cada respuesta.
  3. pubKeyCredParams con -8, -7, -257; excludeCredentials rellenado con las credenciales que ya tiene el usuario.
  4. Desafíos aleatorios, de un solo uso, de corta duración y guardados en el servidor.
  5. Credenciales guardadas con ID, clave pública, contador, transportes, indicadores BE y BS, AAGUID y un nombre que el usuario puede editar; los usuarios pueden registrar varias y borrarlas.
  6. Atestación "none" salvo que restrinjas los modelos de autenticador; en ese caso, verifica con FIDO MDS.
  7. Una recuperación de cuenta que no sea más débil que la propia llave de acceso: una segunda llave de acceso o una vía de recuperación verificada, no un código por SMS que se salte todo lo demás.

Fuentes