12k
All articles

Cómo añadir passkeys a una app de iOS y Android

Añade claves de acceso a apps iOS y Android con Swift y Kotlin. Configura asociaciones de dominio, registro e inicio de sesión, y verifica credenciales en el servidor.

OpenReplay Team
OpenReplay Team
Cómo añadir passkeys a una app de iOS y Android

Una passkey (llave de acceso) es un par de claves pública/privada vinculado a un único dominio. Tu app la crea a través de la API de credenciales de la plataforma, el servidor solo conserva la clave pública, y Face ID o la huella dactilar se limitan a permitir que el dispositivo use la clave privada.

Un fallo habitual es el siguiente: el botón está conectado, el backend funciona en la web y, en un teléfono real, no aparece nada. No hay hoja del sistema, no hay ninguna línea en el log y, a menudo, solo se obtiene un error genérico de cancelación.

La causa casi siempre es una de estas tres: la asociación entre la app y el dominio, una discrepancia entre el ID de relying party del servidor y ese dominio, o una lógica de verificación que el servidor nunca llegó a completar. Esta guía cubre cada pieza en iOS (Swift) y Android (Kotlin), junto con el trabajo de servidor de cada paso. Se da por hecho que ya te has decidido por las passkeys (la comparativa entre contraseñas y passkeys se trata en otro artículo).

Puntos clave

  • Una app solo puede crear o usar una passkey para un dominio cuando la app declara ese dominio y el dominio publica un archivo que nombra a la app: apple-app-site-association con webcredentials en iOS, y assetlinks.json con delegate_permission/common.get_login_creds en Android.
  • El ID de relying party que envía el servidor debe ser el dominio que aloja ambos archivos de asociación, y cambiarlo más adelante deja inutilizables todas las passkeys existentes.
  • Las apps distribuidas con Play App Signing deben incluir en assetlinks.json la huella digital de la clave de firma de apps de Google. Si solo incluyes la clave de subida, las passkeys funcionan en las builds locales y fallan en las instalaciones desde la tienda.
  • Es el servidor quien decide si una passkey es válida. Comprueba un challenge de un solo uso, el origen (incluido el origen android:apk-key-hash: de Android), el hash del rpId y la firma antes de crear una sesión.
  • Las passkeys sincronizadas siguen a la cuenta de Apple o Google del usuario, por lo que la seguridad de tus cuentas pasa a depender en parte de la de esas cuentas. Mantén una vía de recuperación que no dependa de la passkey.

¿Una passkey es lo mismo que el desbloqueo biométrico?

Una passkey no es un desbloqueo por huella dactilar. Face ID o la huella solo permiten que el dispositivo use una clave privada que se creó para un dominio concreto y que nunca sale del dispositivo ni del llavero sincronizado del usuario. El servidor guarda la clave pública correspondiente y verifica una firma. Nunca ve ningún dato biométrico.

El bloqueo biométrico local es un mecanismo distinto. En ese caso, la app desbloquea un token que había almacenado previamente, y el servidor no dispone de ninguna prueba criptográfica de nada. Si es eso lo que necesitas, la autenticación biométrica en React Native explica cómo configurarla. Esta guía no lo repite.

Asociación con la plataforma: el paso que falla en silencio

El ID de relying party (rpId) que envía tu servidor debe ser el mismo dominio que aloja ambos archivos de asociación. El sistema operativo comprueba ambos lados de forma independiente. La app declara el dominio, y el dominio publica un archivo que declara la app. Si cualquiera de los dos lados está mal, la hoja de passkeys nunca aparece, y ninguna de las dos plataformas le dice a la app por qué.

iOS: entitlement webcredentials y apple-app-site-association

Añade el dominio al entitlement Associated Domains con el servicio webcredentials:

<key>com.apple.developer.associated-domains</key>
<array>
    <string>webcredentials:example.com</string>
</array>

Sirve este archivo, sin extensión, en https://example.com/.well-known/apple-app-site-association. El ID de la app es tu Team ID y tu bundle ID unidos por un punto:

{
  "webcredentials": {
    "apps": ["ABCDE12345.com.example.shop"]
  }
}

La guía de Apple sobre compatibilidad con dominios asociados indica que el archivo debe servirse por HTTPS con un certificado de confianza y que la petición no debe redirigir. Como el archivo no tiene extensión, muchos hosts estáticos y almacenes de objetos lo sirven con un content type binario genérico, así que define el tipo de forma explícita:

location = /.well-known/apple-app-site-association {
    default_type application/json;
}
curl -sI https://example.com/.well-known/apple-app-site-association | grep -i content-type
# expect: content-type: application/json

Los dispositivos no descargan este archivo de tu servidor. Leen una copia que la CDN de Apple descarga y almacena en caché. Según la documentación de Apple, la CDN solicita el archivo en un plazo aproximado de 24 horas, y los dispositivos con la app instalada buscan una copia actualizada más o menos una vez por semana. Por tanto, un archivo corregido tarda en llegar a las apps instaladas: primero la CDN tiene que volver a descargarlo, y después cada dispositivo lo recoge al instalar, al actualizar o en su siguiente comprobación semanal. Si el archivo estaba roto cuando la CDN lo descargó, la CDN también almacena en caché ese resultado roto. Para desarrollo, usa webcredentials:example.com?mode=developer y activa Associated Domains Development en Ajustes › Desarrollador en el dispositivo. Así, el dispositivo descarga el archivo directamente de tu dominio. Elimina el sufijo antes de publicar, porque las builds de release dependen de la copia de la CDN.

Android: assetlinks.json con get_login_creds

En Android, las passkeys de Credential Manager requieren Digital Asset Links en el dominio del rpId, servido en https://example.com/.well-known/assetlinks.json:

[
  {
    "relation": ["delegate_permission/common.get_login_creds"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.shop",
      "sha256_cert_fingerprints": [
        "<PLAY_APP_SIGNING_KEY_SHA256>",
        "<UPLOAD_KEY_SHA256>"
      ]
    }
  }
]

Incluye la huella digital de cada keystore con el que firmes:

keytool -list -v -keystore release.keystore -alias release | grep SHA256

Si la app se distribuye con Play App Signing, Google la vuelve a firmar antes de entregarla. La huella digital que cuenta para las instalaciones desde la tienda es la de la clave de firma de apps que aparece en la página de firma de apps (App signing) de Play Console. Incluir solo tu clave de subida local es el motivo por el que las passkeys funcionan en las builds de depuración y fallan para los usuarios que instalaron desde la tienda. Las apps inscritas en la firma híbrida preparada para la computación cuántica de Play tienen más de una clave de firma de apps, así que añade todas las huellas SHA-256 que muestre esa página. Incluir huellas adicionales no causa ningún problema.

Comprobaciones previas

ComprobaciónCómo verificarlaSíntoma si falla
Content-Typecurl -sI sobre la URL del AASANo aparece la hoja en iOS
Sin redireccionescurl -sI devuelve 200, no 301/302No aparece la hoja en ninguna plataforma
Coincidencia de subdominioArchivos servidos en el host exacto del rpIdFunciona en la web, falla en la app
Clave de firma de PlayCompárala con la página de firma de apps de Play ConsoleFunciona en depuración, falla desde la tienda
Copia en la CDN de AppleConsulta la URL de la CDN que aparece más abajoSe sigue usando un archivo antiguo o roto

Los subdominios no heredan la asociación. La especificación de Digital Asset Links identifica un sitio web por su esquema, su host exacto y su puerto, de modo que los archivos en www.example.com no sirven de nada para un rpId example.com. La regla de Apple es la misma: cada subdominio necesita su propio archivo apple-app-site-association.

Ambos archivos se pueden comprobar sin instalar ninguna build. La primera URL devuelve lo que la CDN de Apple tiene almacenado para tu dominio (los ingenieros de Apple la usan para diagnóstico; no es una API documentada). La segunda llama al endpoint statements.list de Google:

curl https://app-site-association.cdn-apple.com/a/v1/example.com

curl "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://example.com&relation=delegate_permission/common.get_login_creds"

Una asociación rota no lanza ninguna excepción que tu herramienta de reporte de fallos pueda capturar. La reproducción de sesiones (session replay) hace visible el patrón: un toque en «Iniciar sesión con passkey», ninguna hoja del sistema y, a continuación, el usuario pasando al campo de contraseña. Después puedes filtrar esas sesiones por build y por variante de firma.

Cómo implementar passkeys: registro

El registro de passkeys en una app nativa consta de tres partes. El servidor genera las opciones con un challenge nuevo y con rp.id establecido en el dominio asociado. La API nativa crea la credencial. El servidor verifica el resultado y almacena la clave pública y el ID de la credencial. Las opciones del servidor tienen este aspecto:

{
  "challenge": "<base64url, random, single-use>",
  "rp": { "id": "example.com", "name": "Example Shop" },
  "user": { "id": "<base64url user handle>", "name": "ada@example.com", "displayName": "Ada" },
  "pubKeyCredParams": [{ "type": "public-key", "alg": -7 }],
  "authenticatorSelection": { "residentKey": "required", "userVerification": "preferred" },
  "attestation": "none",
  "excludeCredentials": []
}

En iOS, ASAuthorizationPlatformPublicKeyCredentialProvider construye la petición:

let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: "example.com")
let request = provider.createCredentialRegistrationRequest(
    challenge: challenge, name: "ada@example.com", userID: userHandle)
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()

// In the delegate:
if let reg = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
    api.finishRegistration(id: reg.credentialID, clientData: reg.rawClientDataJSON,
                           attestation: reg.rawAttestationObject)
}

En Android, añade androidx.credentials:credentials y androidx.credentials:credentials-play-services-auth. La segunda es obligatoria en Android 13 y versiones anteriores, como explica la guía de migración de FIDO2. Toma la versión actual de las notas de la versión. A continuación, pasa el JSON del servidor directamente a CreatePublicKeyCredentialRequest:

val credentialManager = CredentialManager.create(context)
val result = credentialManager.createCredential(
    context = activity,
    request = CreatePublicKeyCredentialRequest(requestJson = optionsJson)
) as CreatePublicKeyCredentialResponse
api.finishRegistration(result.registrationResponseJson)

Después, el servidor sigue la ceremonia de registro de WebAuthn. Comprueba el challenge, el origen y el hash del rpId, y luego almacena el ID de la credencial, la clave pública, el contador de firmas (sign count) y el user handle. Las apps de Android envían un origen android:apk-key-hash:<encoded SHA 256 fingerprint>, y tu servidor tiene que aceptar ese valor exacto como uno de sus orígenes permitidos. Añade un origen por cada certificado de firma.

Inicio de sesión: aserción y verificación en el servidor

El inicio de sesión con passkey sigue el mismo patrón que el registro. El servidor emite un challenge, el dispositivo lo firma con la clave privada y el servidor verifica la firma con la clave pública almacenada. La app nunca decide si una passkey es válida.

let request = provider.createCredentialAssertionRequest(challenge: challenge)
// Delegate receives ASAuthorizationPlatformPublicKeyCredentialAssertion:
// credentialID, rawClientDataJSON, rawAuthenticatorData, signature, userID
val request = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson = optionsJson)))
val result = credentialManager.getCredential(context = activity, request = request)
api.finishSignIn((result.credential as PublicKeyCredential).authenticationResponseJson)

Siguiendo los pasos de verificación de la aserción, el servidor:

  1. Confirma que el challenge coincide con uno que emitió y que no se ha usado.
  2. Comprueba el origen contra la lista de permitidos, incluidos todos los orígenes apk-key-hash de Android.
  3. Comprueba que rpIdHash es igual al hash SHA-256 de tu rpId.
  4. Exige el flag de presencia del usuario (user present) y, además, el de usuario verificado (user verified) si tu política lo requiere.
  5. Verifica la firma con la clave pública almacenada.
  6. Aplica la regla del contador de firmas. La especificación solo compara los contadores cuando el valor almacenado o el recibido es distinto de cero, así que no rechaces un contador igual a cero.

La sincronización convierte las passkeys en una cuestión de recuperación de cuentas

Las passkeys sincronizadas aparecen en dispositivos que nunca han iniciado sesión en tu app. Las passkeys guardadas en el Gestor de contraseñas de Google siguen a la cuenta de Google del usuario a sus otros dispositivos Android con sesión iniciada, y el Llavero de iCloud las sincroniza entre los dispositivos Apple del usuario. Un usuario puede comprarse un teléfono nuevo, abrir tu app e iniciar sesión de inmediato.

La sincronización de passkeys tiene tres consecuencias para la recuperación de cuentas:

  • Teléfono perdido no significa cuenta perdida. Cuando se pierde un dispositivo, la passkey sigue disponible en los demás dispositivos del usuario.
  • La cuenta de la plataforma pasa a formar parte de tu perímetro de seguridad. Lo segura que sea la passkey de tu dominio depende de la cuenta de Apple o Google del usuario, incluidas sus comprobaciones de doble factor y de recuperación.
  • No todo el mundo sincroniza. Los usuarios con la sincronización del llavero desactivada, con un proveedor de terceros o sin una cuenta de Google en el dispositivo pueden seguir perdiendo su única credencial.

Permite varias passkeys por cuenta, añade una pantalla donde los usuarios puedan verlas y revocarlas, y mantén una vía de recuperación por correo electrónico u OTP. En un dispositivo nuevo, un inicio de sesión que recurre a la contraseña pese a existir una passkey sincronizada suele apuntar a un error en el rpId o en la lista de permitidos, más que a un rechazo por parte del usuario.

Alternativa para dispositivos antiguos y usuarios que rechazan las passkeys

Mantén el inicio de sesión con contraseña u OTP junto a las passkeys en el lanzamiento. Las passkeys nativas en Android requieren Android 9 (nivel de API 28) o superior, y usar un proveedor de passkeys de terceros requiere Android 14. Algunos usuarios rechazarán el aviso sea cual sea su dispositivo. Gestiona NoCredentialException y ASAuthorizationError.canceled mostrando el formulario de inicio de sesión existente, no un error.

Ofrece la creación de la passkey después de un inicio de sesión correcto con contraseña, en lugar de imponerla durante el registro. Trata la vía antigua como permanente hasta que tus datos demuestren que puede pasar a un segundo plano. Cómo funciona el inicio de sesión sin contraseña explica por qué las passkeys resisten mejor que los enlaces mágicos y los OTP. Las apps de React Native y Flutter siguen las mismas reglas: cualquier librería puente que elijas llama a estas mismas API nativas, así que los archivos de asociación y las comprobaciones del servidor no cambian.

Conclusión

La mayoría de los errores con passkeys son errores de configuración, no de código. Antes de escribir código nativo, elige el rpId y aloja ambos archivos de asociación en ese dominio exacto. Compruébalos con los dos validadores de curl y añade al servidor la huella digital de Play App Signing y el origen de Android. Cuando ambos validadores devuelvan tu app, las llamadas en Swift y Kotlin anteriores son todo el código de cliente que necesitas.

Preguntas frecuentes

¿Qué versión mínima de iOS necesitan las passkeys en una app nativa?

Las passkeys sincronizadas requieren iOS 16 o posterior, la versión en la que Apple introdujo las passkeys almacenadas en el Llavero de iCloud. Las credenciales creadas con el autenticador de plataforma integrado en iOS 15 o anterior nunca se convierten en passkeys y permanecen en el dispositivo que las creó. Para actualizar una, registra una nueva credencial con el mismo user handle, e iOS sustituirá la credencial antigua por una passkey sincronizada. En Android, el mínimo es Android 9 (nivel de API 28).

¿Pueden los usuarios iniciar sesión en la app con una passkey que crearon en mi sitio web?

Sí, siempre que el sitio web y la app usen el mismo rpId. Una passkey está vinculada al rpId, no al cliente, así que una misma credencial funciona en el navegador y en ambas apps nativas. Las apps nativas de iOS informan de un origen formado por https:// más el rpId, el mismo valor que envía el sitio web. Las apps de Android informan de un origen android:apk-key-hash, que el servidor debe añadir por separado a su lista de permitidos.

¿Puedo probar las passkeys en el emulador de Android y en el simulador de iOS?

En Android, sí. Usa una imagen del emulador con Google Play (no AOSP sin más) con nivel de API 28 o superior, inicia sesión con una cuenta de Google y configura un bloqueo de pantalla, porque es el Gestor de contraseñas de Google el que almacena la passkey. Sin esos requisitos, la creación de la credencial falla con una excepción que parece un error de la app. El simulador de iOS puede comportarse de forma distinta al hardware real, así que realiza las pruebas finales de passkeys en un iPhone físico.

¿Qué errores apuntan a una asociación rota y no a un error de código?

En Android, CreatePublicKeyCredentialDomException durante el registro suele indicar que assetlinks.json falta o no es accesible, contiene un nombre de paquete o una huella digital incorrectos, o está alojado en un host que no coincide con el rpId. En iOS, una asociación rota suele manifestarse como ASAuthorizationError con código 1001, el mismo código que produce una cancelación por parte del usuario. GetCredentialCancellationException en Android significa que el usuario cerró la hoja y no requiere ningún mensaje de error.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.