En esta guía, se detalla cómo permitir que los usuarios registren una llave de acceso para su cuenta, lo que proporciona una alternativa de acceso sin contraseña altamente segura y resistente al phishing.
Requisitos de la base de datos
Para admitir los registros de llaves de acceso, la tabla de credenciales de tu base de datos debe almacenar los siguientes campos:
export interface StoredPasskeyCredential {
id: string; // Base64URL-encoded credential ID (unique lookup key)
passkeyUserId: string; // Associated application user ID
credentialPublicKey: string; // Base64URL-encoded public key used to verify assertion signatures
credentialType: "public-key";
credentialDeviceType: "singleDevice" | "multiDevice"; // Helps distinguish device-bound vs cloud-synced passkeys
credentialBackedUp: boolean; // Boolean backup state reported by the authenticator
aaguid: string; // Authenticator Attestation GUID
providerIcon?: string; // Provider icon derived from the AAGUID registry (dark or light theme URLs)
name: string; // Provider name derived from AAGUID registry
transports: string[]; // Array of transport names (e.g. 'internal', 'hybrid') necessary for exclusion options
lastUsedAt?: number; // Optional epoch timestamp of last sign-in
registeredAt: number; // Registration epoch timestamp
counter: number; // Authenticator sign-in signature counter used to prevent replay attacks
}
Del servidor
Generación de opciones
Crea un extremo que genere parámetros de creación de WebAuthn. Confía en una biblioteca verificada según los estándares de la categoría en lugar de implementar la criptografía de forma manual.
- Usa el ID de RP predefinido: Usa el ID de RP adecuado predefinido como una cadena constante.
- Crea un desafío seguro: Genera un búfer aleatorio criptográficamente seguro y de alta entropía en el servidor, almacénalo de forma segura en la sesión del usuario y codifícalo como Base64URL para la entrega de opciones.
- Evita las llaves de acceso duplicadas: Asigna los IDs de credenciales preexistentes del usuario al array de opciones
excludeCredentials. Esto evita que el autenticador registre credenciales duplicadas en la misma cuenta del proveedor de llaves de acceso. - Aplicar credenciales detectables: Establece
requireResidentKey: trueyresidentKey: "required"en las opciones deauthenticatorSelectionpara solicitar una credencial detectable, que es necesaria para los accesos detectables. - Configura la verificación del usuario: Especifica
userVerification: "preferred"ouserVerification: "required". Muchos casos de uso de cumplimiento (p.ej., finanzas, atención médica) requieren que'required'aplique la verificación del usuario en el momento de la creación. - Determina el alcance del adjunto:
- Flujo de promoción: Cuando propongas la creación de llaves de acceso justo después de los accesos con contraseña estándar o las promociones posteriores al registro, establece
authenticatorAttachment: "platform"para aplicar el autenticador de la plataforma y omitir las solicitudes de llaves de seguridad externas. - Flujo de administración: Cuando se llama desde un panel de seguridad o configuración dedicado en el que se admiten llaves de seguridad externas además del autenticador de la plataforma, omite la propiedad
authenticatorAttachmentpor completo. - Sugerencia: Acepta un parámetro de solicitud
promotion: booleanpara controlar ambos flujos de forma condicional con un solo extremo.
- Flujo de promoción: Cuando propongas la creación de llaves de acceso justo después de los accesos con contraseña estándar o las promociones posteriores al registro, establece
// Options generation example
const options = {
challenge: serverGeneratedBase64UrlChallenge, // Cryptographically random challenge
rp: { id: "example.com", name: "Secure Application" },
user: {
id: userBase64UrlId, // Unique base64url string identifying the account
name: "user@example.com",
displayName: "Jane Doe",
},
pubKeyCredParams: [
{
type: "public-key",
alg: -7,
},
{
type: "public-key",
alg: -257,
},
],
excludeCredentials: userExistingCredentials.map((cred) => ({
type: "public-key",
id: cred.id,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: "required",
requireResidentKey: true,
userVerification: "preferred",
...(isPromotionFlow && { authenticatorAttachment: "platform" }),
},
};
Verificación
- Verificación del desafío: Verifica de forma segura el desafío con el desafío vinculado a la sesión esperado.
- Verifica la presencia del usuario:
- Asegúrate de que la marca de usuario presente (UP) que se muestra en los datos analizados del autenticador sea
truepara confirmar la presencia física del usuario en el momento de la creación.
- Asegúrate de que la marca de usuario presente (UP) que se muestra en los datos analizados del autenticador sea
- Flexibilización de la verificación para "preferred":
- Cuando las opciones de creación especificaron
userVerification: "preferred", la llamada de verificación del servidor DEBE configurarse conrequireUserVerification: false. De lo contrario, los autenticadores que se registren sin verificación del usuario (p.ej., con los bloqueos de pantalla inhabilitados) activarán fallas de verificación del servidor falsas.
- Cuando las opciones de creación especificaron
Lógica del cliente
- Restringe la IU durante la carga de página:
- Cuando se cargue la página, llama a
PublicKeyCredential.getClientCapabilities()y inhabilita el botón "Crear llave de acceso" siconditionalGetopasskeyPlatformAuthenticatorno están disponibles.
- Cuando se cargue la página, llama a
- Invoca la creación y serializa: Decodifica las opciones del servidor con
PublicKeyCredential.parseCreationOptionsFromJSON()y pasa la configuración resultante anavigator.credentials.create().- Llama a
credential.toJSON()para codificar elAuthenticatorAttestationResponseen un objeto válido que se pueda serializar en JSON antes de recuperar el extremo de verificación.
- Llama a
- Controla las excepciones de WebAuthn:
InvalidStateError: Ya existe una llave de acceso coincidente (coincidencia porexcludeCredentials).NotAllowedError: El usuario canceló el diálogo de la llave de acceso de autenticación o se agotó el tiempo de espera.AbortError: Se anuló la operación.SecurityError: Orígenes seguros (HTTPS) o errores de discrepancia de ID de RP (problemas de configuración).
- Separación de bloques try/catch para la API de Signal:
- Une la llamada de verificación del servidor
fetch()en un bloque try/catch. Llama asignalUnknownCredential()cuando falle la recuperación de la verificación del servidor (cualquier estadoresponse.ok === falseo error de red).
- Une la llamada de verificación del servidor
// optionsFetch and registerVerifyFetch are app-defined HTTP methods
import { optionsFetch, registerVerifyFetch } from "./api.js";
async function registerPasskey(isPromotion = false) {
// Verify passkey capability and conditional UI are available
const capabilities = await PublicKeyCredential.getClientCapabilities();
if (
!capabilities.passkeyPlatformAuthenticator ||
!capabilities.conditionalGet
) {
// Hide "Create passkey" buttons and fall back to password flows instead
showStandardPasswordFallbackUI();
return;
}
const creationOptionsJSON = await optionsFetch({ promotion: isPromotion });
const publicKey =
PublicKeyCredential.parseCreationOptionsFromJSON(creationOptionsJSON);
let credential;
try {
// passkey prompt execution
credential = await navigator.credentials.create({ publicKey });
} catch (err) {
if (err.name === "InvalidStateError") {
console.log("A passkey already exists for this account.");
alert("A passkey already exists for this account.");
} else if (err.name === "SecurityError") {
console.error("Configuration RP ID or Secure Context error.");
alert("Configuration RP ID or Secure Context error.");
} else if (err.name === "NotAllowedError") {
console.log("User cancelled the passkey dialog.");
} else if (err.name === "AbortError") {
console.log("The creation operation has been aborted.");
}
return; // Safe API exit, do not signal unknown for standard WebAuthn cancels
}
// Server Verification phase (Segregated Try/Catch)
let encodedResponse = credential.toJSON();
try {
const response = await registerVerifyFetch(encodedResponse);
if (!response.ok) {
// Server verification failed to verify/authenticate the credential (orphaned)
await PublicKeyCredential.signalUnknownCredential({
rpId, // RP ID must match the one defined on the server
credentialId: encodedResponse.id, // Base64URL-encoded credential ID
});
}
} catch (serverErr) {
console.error("Server verification network failure:", serverErr);
await publickeycredential.signalunknowncredential({
rpId, // RP ID must match the one defined on the server
credentialid: encodedresponse.id, // base64url-encoded credential id
});
}
}
Estrategias de resguardo
Resguardo de sincronización de la API de Signal
La API de Signal de WebAuthn (webauthn-signals) es una optimización progresiva que se usa para mantener los administradores de contraseñas sincronizados con el estado de las credenciales del servidor.
- Experiencia de resguardo: Si el navegador no admite
PublicKeyCredential.signalUnknownCredential, la llamada DEBE omitirse de forma segura a través de la detección de funciones (if (PublicKeyCredential.signalUnknownCredential)), y la verificación del servidor simplemente registra la falla sin activar las actualizaciones del administrador.
Resguardo de serialización de JSON sencillo
Instala siempre "webauthn-polyfills" y, luego, impórtalo en el contexto.
Ten en cuenta que, siempre que se admita PublicKeyCredential, también se admitirán PublicKeyCredential.parseCreationOptionsFromJSON y PublicKeyCredential.prototype.toJSON.
import 'webauthn-polyfills';