Saltar al contenido

Implementar passkeys en tu aplicación: WebAuthn del lado del servidor sin dolor

    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.

    PiezaQuién la ejecutaSu papel
    Relying Party (RP)Tu servidorGenera opciones, guarda claves públicas, verifica firmas
    Cliente WebAuthnEl navegadornavigator.credentials.create() / .get(), aplica el origin
    AutenticadorTPM, Secure Enclave, llave FIDO2Guarda la clave privada, firma el challenge
    TransporteCTAP2 (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 autenticada

    4. 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 attestationCuándo apareceQué hacer
    noneLo que pides por defectoNada: extraer clave pública y credentialId
    packedLlaves FIDO2 (YubiKey, etc.)Validar solo si tienes un requisito de certificación
    tpmWindows Hello con TPMIdem: validar la cadena contra la CA del fabricante del TPM
    apple / android-keyPlataformas móvilesRara vez necesario; requiere raíces específicas
    fido-u2fLlaves U2F antiguasFormato 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

    CampoTipoPara qué
    credential_idbytes (único, indexado)Localizar la credencial en el login
    public_keybytes (COSE)Verificar la firma de cada assertion
    sign_countenteroDetección de clonado (con matices)
    user_idFK a usuariosA qué cuenta pertenece
    transportslistaPista para la UI del navegador en el próximo login
    backup_eligible / backup_statebooleanosSaber si es sincronizada y está respaldada
    aaguidbytesModelo de autenticador (opcional, para mostrar «YubiKey 5»)
    created_at / last_used_at / nombremetadatosGestió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)

    Resumen de privacidad

    Esta web utiliza cookies para que podamos ofrecerte la mejor experiencia de usuario posible. La información de las cookies se almacena en tu navegador y realiza funciones tales como reconocerte cuando vuelves a nuestra web o ayudar a nuestro equipo a comprender qué secciones de la web encuentras más interesantes y útiles.