Native passkey implementation is 100x harder than web. Discover every iOS AASA CDN trap, Android signing key mistake and WebView pitfall before you ship.
Vincent
Created: February 19, 2026
Updated: March 3, 2026

"Just add passkeys to our mobile app."
Seven words that have launched countless debugging sessions. Your PM might have seen a passkey iOS demo somewhere: tap Face ID, user is logged in. The pitch writes itself: no passwords involved, better conversion rates, phishing-resistant MFA. A dream for frictionless onboarding and login in iOS and Android apps.
However, you as a developer then have to implement it.

Want to know whether to buy or build your passkey solution? Get our 60-page Buy vs. Build Guide (incl. cost breakdown & adoption strategies). Trusted by leading enterprises.
Passkey implementation is complex on every platform. The initial integration might look relatively straightforward, but the real work starts after launch:
Read our article on 5 passkey issues that surface post-launch for more details.
On native mobile apps, you get all of those post-launch problems plus months of platform-specific setup before you even reach launch. You could debug why passkeys work on iPhone 15 but not iPhone 14, why certain Android OEMs behave differently from your Pixel test device and why Apple's CDN cached your old config for 48 hours. You will discover that "Universal Links" are not universal, that Android has different signing keys and that a single misplaced character in a JSON file will break authentication for your entire user base - silently.
This post documents the native-specific pitfalls. It is based on our experience shipping native passkey integrations and the patterns we see across customer deployments.
Web apps have it easy. The browser sees example.com, trusts example.com. Done.
Native apps have no URL. They are just code on a phone. So Apple and Google invented file-based trust systems that map your app to your domain. Different files, different formats, different failure modes. When they fail - and they most likely will - they fail silently.
Apple's trust system requires you to host a JSON file at
https://yourdomain.com/.well-known/apple-app-site-association:
{ "webcredentials": { "apps": ["TEAMID.com.example.app"] } }
Looks trivial. It is not.
Silent failure modes:
The error message is very generic. You don't get detailed hints.
iOS does not fetch your AASA file directly. It goes through Apple's CDN at
app-site-association.cdn-apple.com. You update your file, deploy it, verify it loads in
your browser and passkeys still do not work because Apple's CDN cached the old version.
Apple's CDN imposes its own caching behavior regardless of your origin headers.
Community investigations
show that observed max-age values vary (commonly around 3600 seconds, but not
guaranteed) and propagation delays are unpredictable. There is no programmatic cache purge.
You can inspect what Apple's CDN currently serves (and its cache headers):
https://app-site-association.cdn-apple.com/a/v1/{yourdomain.com}
Here's a developer bypass (for testing only):
?mode=developer to your Associated Domains entitlementMiss any step and you are back to the cached version. Remove the ?mode=developer flag
before App Store submission or your app gets rejected.
Apple's crawler is strict about the HTTP response:
Content-Type must be application/json (for unsigned files) or application/pkcs7-mime
(for signed files). Adding charset=utf-8 works in practice, but serving it as
text/json or application/octet-stream will break. See
Apple's Associated Domains documentation
for the accepted formats.About 5% of users who try to use passkeys immediately after installing your app will hit
an Application is not associated with this domain error. The reason: iOS has not yet verified
the AASA file for the freshly installed app. iOS 18+ improved this by returning a
retryable error, but on older versions the failure is silent. Build retry logic or delay
the passkey prompt after first install.
Android's equivalent is assetlinks.json, hosted at
https://yourdomain.com/.well-known/assetlinks.json. It needs SHA-256 fingerprints of
your app's signing certificate. The problem: Android has three different signing keys.
The Play Store key is the only one that matters for production users. Every developer who forgets this ships an app that works in testing and fails for 100% of Play Store users.
Get the correct fingerprint from Play Console under Setup > App signing > App signing key certificate > SHA-256 fingerprint.
iOS supports wildcards in Associated Domains (e.g. *.example.com). Android does not.
Need login.example.com, api.example.com and www.example.com? That is three separate
entries in assetlinks.json. Miss one and that subdomain cannot use passkeys. You can also
configure credential sharing via Play Console, but the assetlinks.json file must still
be valid.
Want to experiment with passkey flows? Try our Passkeys Debugger.
You survived domain association. Now you get to implement the same feature twice in two completely different ways.
iOS passkey authentication requires this sequence:
ASAuthorizationPlatformPublicKeyCredentialProviderASAuthorizationControllerperformRequests()The order matters. Mess it up and you get crashes.
The critical encoding trap: WebAuthn uses Base64URL encoding. iOS
returns raw Data objects. If you use a
standard Base64 decoder instead of Base64URL, the + and / characters silently become
- and _. Everything breaks. The server returns "Invalid Response" and nothing in the
error tells you it is an encoding problem.
When you call performRequests(), iOS takes over the screen with a system sheet. You
cannot customize it, theme it or change the text. If the user cancels, you get a generic
ASAuthorizationError.canceled. If the presentation window is not visible (e.g. you
triggered the request during a transition), it crashes.
Your app's design system is irrelevant here. Apple's system sheet is the UI.
User creates a passkey on their iPad. They pick up their iPhone. The passkey is not there yet.
iCloud Keychain sync is not instant. It depends on network conditions, iCloud account status and device state. Your code cannot check sync status, cannot force a sync and cannot detect whether a sync is in progress. The only option is graceful fallback. If the credential is not found, offer an alternative authentication method and let the user retry later.
Android has two passkey APIs:
com.google.android.gms.fido.fido2): the original implementation.
Google now considers it legacy and
recommends migrating
to Credential Manager. It still works but is no longer receiving feature updates.androidx.credentials): the current recommended API. Unifies
passkeys, passwords and federated sign-in (Google Sign-In) into a single bottom sheet.
Available via Jetpack, backported to Android 9+ through Play Services.If you are starting fresh, use Credential Manager. If you have an existing FIDO2 API integration, Google provides a migration guide. The APIs are not compatible. Migrating is a rewrite, not a refactor.
Android passkey support depends on both the OS version and Google Play Services:
Check upfront:
val credentialManager = CredentialManager.create(context)
If credential creation or retrieval throws NoCredentialException on a device you expect
to work, check the Play Services version first.
Pixel: works as documented (mostly).
Samsung: Samsung Pass and Google Password Manager can both register as credential
providers. When both are active, users may see confusing duplicate prompts. If your app combines passkeys with Google Sign-In via
GetGoogleIdOption (common in Credential Manager setups), multiple Google accounts on the
device can trigger a TransactionTooLargeException that crashes the entire credential
selection flow. This specific bug was
documented on StackOverflow
and fixed in Google Play Services 24.40+, but older versions are still widespread.
Xiaomi: some models report "Device doesn't support credentials" even though the hardware and OS version qualify. Xiaomi's ROM modifications interfere with Credential Manager.
Huawei (without Google Play Services): no support. You need a completely separate authentication path.
If the user has "Swipe to Unlock" (no PIN, pattern or biometric), passkeys silently fail.
The error you get is a generic SecurityError with no indication that the problem is the
lock screen configuration.
Check before attempting any passkey operation:
val keyguardManager = getSystemService(KeyguardManager::class.java) if (!keyguardManager.isDeviceSecure) { // tell the user to set up a PIN or biometric }
Note: isDeviceSecure (checks for PIN/pattern/password) is what you want, not
isKeyguardSecure (which returns true even for swipe-to-unlock on some OEMs).

Looking for a dev-focused passkey reference? Download our Passkeys Cheat Sheet. Trusted by dev teams at Ally, Stanford CS & more.
If your app wraps a website in a WebView, you are in for additional pain.
The origin in a passkey ceremony depends on the context:
https://example.comandroid:apk-key-hash:sha256_abc123...A passkey created in one context will not work in another unless your server explicitly accepts multiple origins. If you do not configure this, authentication fails with a "SecurityError" or, worse, a phishing detection warning.
Read our detailed blog post on origin validation in native apps for details.
On Android, WebView passkey support is not enabled by default. You need the
WebViewCredentialProvider from the Jetpack libraries and proper assetlinks.json
configuration. The documentation is sparse.
Some developers try to bridge native passkey APIs into WebView via JavaScript:
window.nativeBridge.authenticateWithPasskey()
This is a security risk. Any XSS vulnerability or malicious injected script can call your bridge and trigger authentication. Google's documentation explicitly warns against this pattern.
Using SFSafariViewController (iOS) or Chrome Custom Tabs (Android) for authentication
works and avoids the origin mismatch problem. The trade-off: it looks like a browser, not
your app. A URL bar is visible. Users get confused about whether they are still in your
app. Returning to the app after authentication is not always seamless - deep link handling
adds yet another layer of configuration.
You want passkeys to appear in the keyboard suggestions when the user taps a username field. This is Conditional UI and it is unreliable in native apps.
Set textContentType = .username on your text field. The passkey should appear in the
QuickType bar. In practice:
Android's Credential Manager can auto-select a passkey if you set
isAutoSelectAllowed = true. But the flow is fragile:
GetCredentialCancellationExceptionGetCredentialCancellationExceptionOn Android 13 and below, the password save dialog and the passkey creation dialog can appear simultaneously after a login, fighting each other for the user's attention. Users click the wrong one and no passkey gets created.
Igor Gjorgjioski
Head of Digital Channels & Platform Enablement, VicRoads
Corbado proved to be a trusted partner. Their hands-on, 24/7 support and on-site assistance enabled a seamless integration into VicRoads' complex systems, offering passkeys to 5 million users.
Passkeys that millions adopt, fast. Start with Corbado's Adoption Platform.
Start Free TrialA passkey created on an iPhone might live in iCloud Keychain. A passkey created on a Pixel might live in Google Password Manager. User switches platforms? The passkeys do not come along if the credential manager is not available on the other device.
This means your system still needs fallback authentication. If you did not build a recovery flow, the user who switches from iPhone to Android probably cannot log in.
The FIDO Alliance published working drafts of the Credential Exchange Protocol (CXP) and Credential Exchange Format (CXF) to address this. Apple, Google, Microsoft, 1Password, Dashlane and Bitwarden are all contributing. But there is no shipping implementation yet.
The fallback for cross-platform use is hybrid transport - scan a QR code with your phone to authenticate on another device. It works, but:
This is not a reliable primary flow. Treat it as a backup option.
The iOS Simulator fakes Face ID / Touch ID with a simple toggle. The Android Emulator
fakes fingerprint with adb commands. Neither accurately represents:
Bugs that crash production are invisible in simulators. See our native passkey testing guide for details.
Minimum viable test set:
Test scenarios that break in production:
UI testing frameworks (XCTest, Espresso) cannot interact with system biometric prompts. You cannot tap "Face ID" in an automated test. Options:
BiometricManager.Authenticators.DEVICE_CREDENTIAL for testing without
biometricsThere is no way around manual testing for the full end-to-end flow.
Native passkey implementation is not a simple feature you can implement over night. It is more a sub-project. Passkeys are complex on every platform, but the native-specific layer - domain association, dual platform APIs, OEM fragmentation, WebView origin handling - adds tons of work on top of the challenges you already face on the web.
The domain association layer alone (AASA files, CDN caching, signing keys,
assetlinks.json) can consume many resources. Then you implement the same feature twice in two
incompatible APIs. Then you discover that every Android OEM behaves differently. Then you
learn that your WebView approach needs a completely different origin configuration. Then
you realize simulators hid half your bugs.
When it works, the UX is genuinely good. One tap, logged in, unphishable. But getting there requires budgeting the time honestly: plan for 3-6 months, not 2 weeks. Build fallback flows from day one. Test on physical devices. And read the WebAuthn errors guide before you ship.
Related Articles
Table of Contents