How to Add Passkeys to an iOS and Android App
Add passkeys to iOS and Android apps with Swift and Kotlin. Configure domain associations, implement registration and sign-in, and verify credentials securely on your server.
A passkey is a public/private key pair scoped to one domain. Your app creates it through the platform credential API, the server keeps only the public key, and Face ID or a fingerprint does nothing more than let the device use the private key.
A common way this goes wrong: the button is wired up, the backend works on the web, and on a real phone nothing appears. There’s no system sheet, no log line, and often just a generic cancellation error.
The cause is almost always one of three things: the app-to-domain association, a mismatch between the server’s relying party ID and that domain, or verification logic the server never finished. This guide covers each piece on iOS (Swift) and Android (Kotlin), with the server work for each step. It assumes you’ve already decided in favour of passkeys (the passwords-versus-passkeys trade-off is covered elsewhere).
Key Takeaways
- An app can create or use a passkey for a domain only when the app declares that domain and the domain publishes a file naming the app:
apple-app-site-associationwithwebcredentialson iOS, andassetlinks.jsonwithdelegate_permission/common.get_login_credson Android. - The relying party ID the server sends must be the domain hosting both association files, and changing it later leaves every existing passkey unusable.
- Apps distributed with Play App Signing must list Google’s app signing key fingerprint in
assetlinks.json. If you list only the upload key, passkeys work in local builds and fail for store installs. - The server decides whether a passkey is valid. It checks a single-use challenge, the origin (including Android’s
android:apk-key-hash:origin), the rpId hash and the signature before it creates a session. - Synced passkeys follow the user’s Apple or Google account, so your account security now depends partly on theirs. Keep a recovery path that doesn’t rely on the passkey.
Is a Passkey the Same as Biometric Unlock?
A passkey is not fingerprint unlock. Face ID or a fingerprint only lets the device use a private key that was created for one domain and never leaves the device or the user’s synced keychain. The server holds the matching public key and checks a signature. It never sees any biometric data.
Local biometric gating is a different mechanism. There, the app unlocks a token it stored earlier, and the server has no cryptographic proof of anything. If that’s what you need, biometric authentication in React Native covers the setup. This guide doesn’t repeat it.
Platform Association: The Step That Fails Silently
The relying party ID (rpId) your server sends must be the same domain that hosts both association files. The operating system checks both sides independently. The app lists the domain, and the domain publishes a file listing the app. If either side is wrong, the passkey sheet never appears, and neither platform tells the app why.
iOS: webcredentials Entitlement and apple-app-site-association
Add the domain to the Associated Domains entitlement with the webcredentials service:
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:example.com</string>
</array>
Serve this file, with no extension, at https://example.com/.well-known/apple-app-site-association. The app ID is your Team ID and bundle ID joined by a dot:
{
"webcredentials": {
"apps": ["ABCDE12345.com.example.shop"]
}
}
Apple’s supporting associated domains guide says the file must come over HTTPS with a trusted certificate, and the request must not redirect. Because the file has no extension, many static hosts and object stores serve it with a generic binary content type, so set the type explicitly:
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
Devices don’t fetch this file from your server. They read a copy that Apple’s CDN fetches and caches. Apple’s documentation says the CDN requests the file within about 24 hours, and installed devices check for an updated copy roughly once a week. A corrected file therefore takes time to reach installed apps: the CDN has to re-fetch it first, and then each device picks it up on install, on update, or at its next weekly check. If the file was broken when the CDN fetched it, the CDN caches that broken result too. For development, use webcredentials:example.com?mode=developer and turn on Associated Domains Development under Settings › Developer on the device. The device then fetches the file directly from your domain. Remove the suffix before release, because release builds rely on the CDN copy.
Android: assetlinks.json With get_login_creds
On Android, Credential Manager passkeys need Digital Asset Links at the rpId domain, served at 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>"
]
}
}
]
List the fingerprint of any keystore you sign with:
keytool -list -v -keystore release.keystore -alias release | grep SHA256
If the app ships with Play App Signing, Google re-signs it before delivery. The fingerprint that counts for store installs is the app signing key shown on the App signing page in Play Console. Listing only your local upload key is why passkeys work in debug builds and fail for users who installed from the store. Apps enrolled in Play’s quantum-ready hybrid signing have more than one app signing key, so add every SHA-256 fingerprint that page shows. Listing extra fingerprints does no harm.
Pre-Flight Checks
| Check | How to verify | Symptom if wrong |
|---|---|---|
| Content-Type | curl -sI on the AASA URL | No sheet on iOS |
| No redirects | curl -sI returns 200, not 301/302 | No sheet on either platform |
| Subdomain match | Files served at the exact rpId host | Works on web, fails in app |
| Play signing key | Compare with Play Console’s App signing page | Works in debug, fails from the store |
| Apple CDN copy | Query the CDN URL below | Old or broken file still in use |
Subdomains don’t inherit association. The Digital Asset Links specification identifies a website by its scheme, exact host and port, so files at www.example.com do nothing for an rpId of example.com. Apple’s rule is the same: each subdomain needs its own apple-app-site-association file.
Both files can be checked without installing a build. The first URL returns what Apple’s CDN holds for your domain (Apple engineers use it for diagnosis; it isn’t a documented API). The second calls Google’s statements.list endpoint:
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"
A broken association throws no exception your crash reporter can catch. Session replay makes the pattern visible: a tap on “Sign in with passkey”, no system sheet, then the user switching to the password field. You can then filter those sessions by build and signing variant.
How to Implement Passkeys: Registration
Passkey registration in a native app has three parts. The server creates options with a fresh challenge and rp.id set to the associated domain. The native API creates the credential. The server verifies the result and stores the public key and credential ID. The server’s options look like this:
{
"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": []
}
On iOS, ASAuthorizationPlatformPublicKeyCredentialProvider builds the request:
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)
}
On Android, add androidx.credentials:credentials and androidx.credentials:credentials-play-services-auth. The second is required on Android 13 and lower, as the FIDO2 migration guide explains. Take the current version from the release notes. Then pass the server’s JSON straight to CreatePublicKeyCredentialRequest:
val credentialManager = CredentialManager.create(context)
val result = credentialManager.createCredential(
context = activity,
request = CreatePublicKeyCredentialRequest(requestJson = optionsJson)
) as CreatePublicKeyCredentialResponse
api.finishRegistration(result.registrationResponseJson)
The server then follows the WebAuthn registration ceremony. It checks the challenge, the origin and the rpId hash, then stores the credential ID, public key, sign count and user handle. Android apps send an origin of android:apk-key-hash:<encoded SHA 256 fingerprint>, and your server has to accept that exact value as one of its allowed origins. Add one origin per signing certificate.
Sign-In: Assertion and Server Verification
Passkey sign-in follows the same pattern as registration. The server issues a challenge, the device signs it with the private key, and the server verifies the signature against the stored public key. The app never decides whether a passkey is valid.
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)
Following the assertion verification steps, the server:
- Confirms the challenge matches one it issued and hasn’t been used.
- Checks the origin against the allow-list, including every Android
apk-key-hashorigin. - Checks that
rpIdHashequals the SHA-256 hash of your rpId. - Requires the user-present flag, plus user-verified if your policy demands it.
- Verifies the signature with the stored public key.
- Applies the sign-count rule. The spec compares counters only when the stored or received value is nonzero, so don’t reject a counter of zero.
Syncing Turns Passkeys Into an Account-Recovery Question
Synced passkeys show up on devices that never signed in to your app. Passkeys saved to Google Password Manager follow the user’s Google account to their other signed-in Android devices, and iCloud Keychain syncs them across the user’s Apple devices. A user can buy a new phone, open your app and sign in straight away.
Passkey syncing has three consequences for account recovery:
- Lost phone, not lost account. When a device is lost, the passkey is still available on the user’s other devices.
- The platform account is now part of your security boundary. How safe the passkey for your domain is depends on the user’s Apple or Google account, including its two-factor and recovery checks.
- Not everyone syncs. Users with keychain sync turned off, a third-party provider or no Google account on the device can still lose their only credential.
Allow several passkeys per account, add a screen where users can see and revoke them, and keep an email or OTP recovery route. On a fresh device, a sign-in that falls back to a password despite a synced passkey usually points to an rpId or allow-list bug rather than the user declining.
Fallback for Old Devices and Refusers
Keep password or OTP sign-in alongside passkeys at launch. Native passkeys on Android need Android 9 (API level 28) or higher, and using a third-party passkey provider needs Android 14. Some users will decline the prompt no matter what device they have. Handle NoCredentialException and ASAuthorizationError.canceled by showing the existing login form, not an error.
Offer passkey creation after a successful password sign-in instead of forcing it at signup. Treat the older path as permanent until your data shows it can be demoted. How passwordless login works explains why passkeys hold up better than magic links and OTPs. React Native and Flutter apps follow the same rules: whatever bridge library you choose calls these same native APIs, so the association files and server checks don’t change.
Conclusion
Most passkey bugs are configuration bugs, not code bugs. Before any native code, pick the rpId and host both association files at that exact domain. Check them with the two curl validators, and add the Play App Signing fingerprint and the Android origin to the server. Once both validators return your app, the Swift and Kotlin calls above are all the client code you need.
FAQs
What minimum iOS version do passkeys need in a native app?
Synced passkeys need iOS 16 or later, the release where Apple introduced passkeys stored in iCloud Keychain. Credentials made with the built-in platform authenticator on iOS 15 or earlier never become passkeys and stay on the device that created them. To upgrade one, register a new credential with the same user handle, and iOS replaces the old credential with a synced passkey. On Android, the floor is Android 9 (API level 28).
Can users sign in to the app with a passkey they created on my website?
Yes, as long as the website and the app use the same rpId. A passkey is scoped to the rpId, not to the client, so one credential works in the browser and in both native apps. iOS native apps report an origin of https:// plus the rpId, the same value the website sends. Android apps report an android:apk-key-hash origin, which the server must add to its allow-list separately.
Can I test passkeys on the Android emulator and the iOS Simulator?
On Android, yes. Use an emulator image with Google Play (not plain AOSP) at API level 28 or higher, sign in with a Google account, and set a screen lock, because Google Password Manager stores the passkey. Without those, credential creation fails with an exception that looks like an app bug. The iOS Simulator can behave differently from real hardware, so run final passkey tests on a physical iPhone.
Which errors point to a broken association rather than a code bug?
On Android, CreatePublicKeyCredentialDomException during registration usually means assetlinks.json is missing or unreachable, lists the wrong package name or fingerprint, or sits on a host that does not match the rpId. On iOS, a broken association often shows up as ASAuthorizationError code 1001, the same code a user cancel produces. GetCredentialCancellationException on Android means the user dismissed the sheet and needs no error message.