Implementar passkeys en tu aplicación:
WebAuthn del lado del servidor sin dolor
Seguridad de aplicaciones web | unfantasmaenelsistema.com
En nuestro artículo sobre passkeys y FIDO2 explicamos el «qué» y el «por qué»: criptografía de clave pública aplicada al login, resistencia al phishing por diseño y el fin —por fin— de la contraseña. Este artículo es la otra mitad: el «cómo». Cómo se integra WebAuthn en una aplicación real desde el punto de vista del servidor, qué datos hay que generar, qué hay que validar en cada paso y qué decisiones de diseño vas a tener que tomar antes de escribir la primera línea.
La buena noticia es que WebAuthn del lado del servidor es más simple de lo que parece: son dos flujos (registro y autenticación), cada uno con una llamada para pedir parámetros y otra para verificar la respuesta. La mala noticia es que casi toda la complejidad real está en los detalles de validación, y ahí es donde una librería madura te ahorra semanas y varios fallos de seguridad. Vamos por partes.
Qué aprenderás en este artículo
Los dos flujos de WebAuthn: registro (attestation) y autenticación (assertion) · Qué genera y qué valida el servidor en cada paso: challenge, rpId, origin, signCount · Qué es la attestation, qué formatos existen y por qué la mayoría de aplicaciones pueden ignorarla · Cómo modelar en base de datos la credencial: clave pública, credentialId y contador · Passkeys sincronizadas vs. ligadas al dispositivo, y credenciales descubribles (login sin usuario) · Por qué usar una librería (SimpleWebAuthn, webauthn4j, py_webauthn) y no parsear CBOR a mano
1. El reparto de papeles: navegador, autenticador y servidor
En WebAuthn hay tres actores. El autenticador es lo que custodia la clave privada y firma: el Secure Enclave del móvil, el TPM del portátil, una llave física USB. El cliente es el navegador, que expone la API «navigator.credentials» y habla con el autenticador mediante el protocolo CTAP. Y tu servidor —la Relying Party o RP— es quien inicia cada operación, guarda las claves públicas y verifica las firmas.
Tu código de servidor nunca habla con el autenticador directamente. Solo hace dos cosas: producir un objeto de opciones que el navegador pasará al autenticador, y verificar el objeto de respuesta que el navegador devuelve. Todo lo demás —diálogos de huella, PIN, selección de llave— ocurre en capas que no controlas ni necesitas controlar.
| Pieza | Quién la ejecuta | Su papel |
|---|---|---|
| Relying Party (RP) | Tu servidor | Genera opciones, guarda claves públicas, verifica firmas |
| Cliente WebAuthn | El navegador | navigator.credentials.create() / .get(), aplica el origin |
| Autenticador | TPM, Secure Enclave, llave FIDO2 | Guarda la clave privada, firma el challenge |
| Transporte | CTAP2 (USB, NFC, BLE, interno) | Une navegador y autenticador |
1.1 rpId: el identificador que ata la passkey a tu dominio
El rpId es normalmente tu dominio registrable: «ejemplo.com». Una passkey creada con rpId «ejemplo.com» solo puede usarse en «ejemplo.com» y sus subdominios, y el navegador lo comprueba antes de firmar nada. Esta es la raíz de la resistencia al phishing: aunque la víctima acabe en «ejemp1o.com», el navegador no ofrecerá la passkey porque el rpId no coincide. El servidor no puede «pedir» un rpId arbitrario: tiene que ser un sufijo del origin real de la página.
2. Flujo de registro (attestation)
El registro ocurre una vez por credencial: el usuario ya está autenticado (por contraseña, enlace mágico, sesión existente) y quiere añadir una passkey a su cuenta.
2.1 Paso 1: el servidor genera las opciones de creación
El servidor construye un objeto «PublicKeyCredentialCreationOptions». Los campos que importan:
- challenge: un valor aleatorio de al menos 16 bytes, generado con un CSPRNG. Se guarda en la sesión del servidor para compararlo después. Es de un solo uso.
- rp: { id: «ejemplo.com», name: «Ejemplo» }
- user: { id, name, displayName }. El id debe ser un identificador interno opaco y estable (no el email, que puede cambiar), en bytes.
- pubKeyCredParams: algoritmos aceptados por orden de preferencia. Como mínimo -7 (ES256) y -257 (RS256).
- excludeCredentials: la lista de credentialId que el usuario ya tiene registrados, para que el autenticador no cree un duplicado en el mismo dispositivo.
- authenticatorSelection: aquí decides si quieres «residentKey: required» (credencial descubrible) y qué «userVerification» exiges («preferred» o «required»).
- attestation: «none» para la mayoría de casos (ver sección 4).
// Opciones de registro que devuelve el servidor (JSON, valores en base64url)
{
"challenge": "P4Xy...Kk", // aleatorio, guardado en sesion
"rp": { "id": "ejemplo.com", "name": "Ejemplo" },
"user": {
"id": "dXNlcl8xMjM", // ID interno opaco, NO el email
"name": "ana@ejemplo.com",
"displayName": "Ana García"
},
"pubKeyCredParams": [
{ "type": "public-key", "alg": -7 }, // ES256
{ "type": "public-key", "alg": -257 } // RS256
],
"excludeCredentials": [
{ "type": "public-key", "id": "m9k2...aQ" }
],
"authenticatorSelection": {
"residentKey": "required",
"userVerification": "preferred"
},
"attestation": "none",
"timeout": 60000
}2.2 Paso 2: el navegador responde y el servidor verifica
El navegador llama a «navigator.credentials.create()», el autenticador genera un par de claves, y el cliente devuelve un objeto con «clientDataJSON» y «attestationObject». El servidor tiene que comprobar, en este orden:
- El tipo en clientDataJSON es «webauthn.create».
- El challenge de clientDataJSON coincide con el que se guardó en la sesión.
- El origin de clientDataJSON está en tu lista blanca exacta (p. ej. «https://ejemplo.com»). Comparación literal, nunca «contains».
- El hash del rpId dentro de authData coincide con SHA-256(«ejemplo.com»).
- Los flags de authData: bit UP (user present) activo; bit UV (user verified) activo si exigiste verificación.
- Si pediste attestation distinta de «none»: validar la cadena de la sentencia de attestation.
- Extraer la clave pública (COSE), el credentialId y el signCount inicial, y guardarlos.
Errores de validación que se ven en producción
Aceptar el registro sin comparar el challenge con el de la sesión: permite reproducir respuestas capturadas · Comparar el origin con «startsWith» o «includes»: «https://ejemplo.com.atacante.net» pasaría el filtro · No guardar el credentialId y buscar la credencial solo por usuario: rompe el login sin usuario y complica la rotación · Confiar en signCount de forma estricta con passkeys sincronizadas, que legítimamente devuelven 0 siempre (ver sección 5)
3. Flujo de autenticación (assertion)
El login es más simple porque no hay attestation ni claves nuevas: solo una firma sobre un challenge.
3.1 Paso 1: opciones de solicitud
El servidor genera un «PublicKeyCredentialRequestOptions» con un challenge nuevo (otra vez aleatorio, otra vez guardado en sesión), el rpId, el userVerification deseado y, opcionalmente, allowCredentials: la lista de credentialId de ese usuario.
Si vas a hacer login sin usuario (el usuario aún no ha escrito nada), dejas «allowCredentials» vacío. El navegador mostrará las passkeys que tenga para ese rpId y el usuario elige. Para que esto funcione, la credencial tuvo que crearse como descubrible («residentKey: required» en el registro).
3.2 Paso 2: verificar la assertion
El navegador devuelve «clientDataJSON», «authenticatorData», «signature» y «userHandle». El servidor:
- Busca la credencial por el credentialId recibido (campo «id» / «rawId»).
- Si es login sin usuario, usa userHandle para resolver de qué cuenta se trata.
- Comprueba tipo «webauthn.get», challenge de sesión, origin exacto y hash del rpId, igual que en registro.
- Verifica los flags UP y, si aplica, UV.
- Reconstruye los datos firmados (authenticatorData || SHA-256(clientDataJSON)) y verifica la firma con la clave pública almacenada.
- Compara el signCount recibido con el guardado y actualiza el valor (con la salvedad de la sección 5).
- Solo entonces: crea la sesión de usuario.
# Pseudocodigo de verificacion de una assertion (con libreria)
cred = db.get_credential(response.id) # por credentialId
assert cred is not None
result = webauthn.verify_authentication_response(
credential=response,
expected_challenge=session['challenge'], # de un solo uso
expected_rp_id='ejemplo.com',
expected_origin='https://ejemplo.com',
credential_public_key=cred.public_key,
credential_current_sign_count=cred.sign_count,
require_user_verification=False,
)
db.update_sign_count(cred.id, result.new_sign_count)
session.regenerate() # nueva sesion autenticada4. Attestation: qué es y por qué normalmente la ignoras
La attestation es una prueba criptográfica, firmada por el fabricante del autenticador, de qué modelo de dispositivo generó la clave. Sirve para un caso concreto: entornos regulados (banca, administración pública, defensa) que necesitan garantizar que solo se usan llaves certificadas de modelos aprobados.
Para una aplicación normal, pedir attestation tiene más costes que beneficios: expone un identificador que puede correlacionar usuarios, obliga a mantener y actualizar la lista de metadatos FIDO (MDS), y las passkeys sincronizadas de plataformas suelen devolver attestation «none» de todas formas. La recomendación por defecto es attestation: «none»: sigues obteniendo la clave pública y toda la seguridad del protocolo, sin el equipaje.
| Formato de attestation | Cuándo aparece | Qué hacer |
|---|---|---|
| none | Lo que pides por defecto | Nada: extraer clave pública y credentialId |
| packed | Llaves FIDO2 (YubiKey, etc.) | Validar solo si tienes un requisito de certificación |
| tpm | Windows Hello con TPM | Idem: validar la cadena contra la CA del fabricante del TPM |
| apple / android-key | Plataformas móviles | Rara vez necesario; requiere raíces específicas |
| fido-u2f | Llaves U2F antiguas | Formato heredado, aún soportado por las librerías |
5. Passkeys sincronizadas vs. ligadas al dispositivo
Una passkey sincronizada vive en un llavero en la nube (iCloud, el gestor de contraseñas de Google, 1Password, Bitwarden) y aparece en todos los dispositivos del usuario. Una passkey ligada al dispositivo (device-bound) nunca sale del hardware que la creó: es el caso de las llaves FIDO2 físicas y de algunas configuraciones empresariales.
Desde el servidor la diferencia se nota sobre todo en dos sitios:
- signCount: las llaves físicas incrementan un contador en cada uso, y un contador que retrocede es señal de clonado. Las passkeys sincronizadas casi siempre devuelven 0 permanentemente porque un contador no tendría sentido replicado en varios dispositivos. Conclusión: trata una regresión de signCount como una señal para revisar, no como un motivo automático para rechazar el login, y no te sorprendas de ver 0 siempre.
- Recuperación: con passkeys sincronizadas, si el usuario pierde el móvil recupera el acceso al recuperar su cuenta de plataforma. Con device-bound, perder el dispositivo es perder la credencial: necesitas que el usuario tenga registrada más de una.
El flag BE (backup eligible) y BS (backup state) de authData te dicen si una credencial es sincronizable y si está respaldada. Puedes guardarlos para, por ejemplo, avisar al usuario de que su única passkey es device-bound y le conviene añadir otra.
Decisiones de diseño antes de empezar
¿Passkeys como segundo factor o como método único de login? Define userVerification en consecuencia · ¿Quieres login sin usuario? Entonces residentKey: required y guarda bien el credentialId y el userHandle · Permite SIEMPRE registrar varias passkeys por cuenta y muestra una lista para revocarlas · Mantén un método de recuperación robusto: es el eslabón que el atacante va a atacar
6. Modelo de datos y uso de una librería
6.1 Qué guardar por credencial
| Campo | Tipo | Para qué |
|---|---|---|
| credential_id | bytes (único, indexado) | Localizar la credencial en el login |
| public_key | bytes (COSE) | Verificar la firma de cada assertion |
| sign_count | entero | Detección de clonado (con matices) |
| user_id | FK a usuarios | A qué cuenta pertenece |
| transports | lista | Pista para la UI del navegador en el próximo login |
| backup_eligible / backup_state | booleanos | Saber si es sincronizada y está respaldada |
| aaguid | bytes | Modelo de autenticador (opcional, para mostrar «YubiKey 5») |
| created_at / last_used_at / nombre | metadatos | Gestión desde el perfil del usuario |
6.2 Usa una librería, no parsees CBOR a mano
El «attestationObject» y las claves COSE van codificados en CBOR; authData es una estructura binaria con offsets; la verificación de firma cambia según el algoritmo; hay casos límite con U2F heredado. Todo eso está resuelto y probado en librerías mantenidas:
- JavaScript / TypeScript: SimpleWebAuthn (@simplewebauthn/server) — la más usada en Node.
- Java / Kotlin: webauthn4j, o java-webauthn-server de Yubico.
- Python: py_webauthn (Duo Labs).
- Go: go-webauthn.
- PHP: web-auth/webauthn-lib.
- .NET: fido2-net-lib.
Tu código se reduce a: pedir a la librería el objeto de opciones, serializarlo a JSON, y pasarle la respuesta del navegador junto con el challenge, el rpId y el origin esperados para que verifique. La librería te devuelve la clave pública y el contador ya extraídos.
Marco legal y buenas prácticas de prueba
Implementa y prueba WebAuthn contra TU aplicación, en tu entorno de desarrollo o preproducción, o contra proyectos deliberadamente vulnerables pensados para ello (OWASP Juice Shop, webauthn.io) · Probar la robustez de la autenticación de sistemas de terceros sin autorización escrita del responsable puede constituir delito en España (arts. 197 bis, 197 ter y 264 del Código Penal) · El user.id y el userHandle no deben contener datos personales: usa identificadores opacos y ten en cuenta el RGPD al registrar metadatos del autenticador
7. Degradación, recuperación y errores comunes
- Detección de soporte: comprueba «window.PublicKeyCredential» y, para UX fina, «isConditionalMediationAvailable()» (autocompletado de passkey en el campo de usuario).
- Conditional UI: el login por passkey puede ofrecerse como sugerencia de autocompletado sin un botón dedicado; requiere credenciales descubribles y «mediation: ‘conditional'».
- Fallback: mantén un segundo método (OTP, enlace por correo) mientras la adopción de passkeys crece, pero cuidado: el método de recuperación más débil marca la seguridad real de la cuenta.
- Reintentos: si la verificación falla, invalida el challenge de la sesión y genera uno nuevo. Nunca reutilices un challenge.
- Multi-origen: si tu app vive en varios dominios o en una app nativa, mantén una lista blanca explícita de origins válidos y, para apps, valida los orígenes tipo «android:apk-key-hash:».
Conclusión
Integrar WebAuthn en el servidor son dos flujos simétricos —registro y autenticación— donde tu código solo genera un objeto de opciones con un challenge aleatorio y verifica la respuesta comprobando challenge, origin exacto, rpId y firma. La complejidad está en los detalles de validación, y por eso la decisión más rentable es apoyarse en una librería madura en lugar de parsear CBOR y cadenas de attestation a mano.
Antes de escribir código, decide tres cosas: si las passkeys son factor único o segundo factor, si quieres login sin usuario (y por tanto credenciales descubribles), y cómo va a recuperar la cuenta quien pierda todos sus dispositivos. Esa última pregunta es la importante: una vez que el login por passkey resiste el phishing por diseño, el atacante irá directo al flujo de recuperación.
Referencias
w3.org/TR/webauthn-3 — Especificación WebAuthn Level 3 del W3C · passkeys.dev — Guía de implementación mantenida por la FIDO Alliance y la comunidad · simplewebauthn.dev — Documentación y guía paso a paso (servidor y navegador) · webauthn.io y webauthn.me — Bancos de pruebas interactivos para depurar tu implementación · fidoalliance.org/metadata — Servicio de metadatos (MDS) para validar attestation si lo necesitas · Nuestro artículo «Passkeys y FIDO2: el fin de la contraseña, explicado sin humo» para el fondo conceptual
unfantasmaenelsistema.com | Seguridad, sistemas y todo lo que no debería funcionar (pero funciona)

