Passkeys in Native Mobile Apps: Associated Domains and Fallbacks
For developers wiring passkeys into iOS and Android apps and needing the web origin, app association files, and fallback sign-in flow to behave predictably. This guide shows where platform credential managers sit in the auth architecture, how the end-to-end flow works, what to configure on your domain, and how to avoid the common production failures.
TL;DR — In a native mobile app, passkeys only become first-class sign-in credentials when the app is cryptographically linked to your web origin via associated domains/app links and your backend speaks WebAuthn correctly. The most common fix is not in app code: publish the correct association files at
/.well-known/, over HTTPS, with no redirects and the right app identifiers, then keep a recovery sign-in path that does not silently downgrade security. Reading time: ~8 min
What it is and where it sits
In a native mobile app, passkeys are usually created and used through the platform credential manager, but they are still bound to a WebAuthn relying party ID, which is effectively your web domain. That means your app is not an island: iOS and Android need proof that app.example.com or example.com is legitimately associated with your app package/bundle before the OS will offer passkeys for that domain inside the app.
Architecturally, this sits between three layers:
- the native app UI calling platform auth APIs
- the platform credential manager / authenticator stack
- your backend WebAuthn server verifying challenges, origins, RP ID, counters, and attestation policy
It replaces, or at least de-emphasizes:
- password entry screens
- app-managed credential storage
- SMS OTP as the primary sign-in path
It does not replace:
- account recovery
- device bootstrap for first install on a new phone
- server-side session issuance and revocation
- your need to own and correctly serve the RP domain
A typical request flow looks like this:
View as text
[User] ---- 1. tap "Sign in" ----> [Native App] [Native App] ---- 2. get challenge ----> [Backend] [Backend] ---- 3. challenge JSON ----> [Native App] [Native App] ---- 4. request passkey ----> [Platform Credential Manager] [Platform Credential Manager] ---- 5. verify app<->domain link ----> [example.com/.well-known] [Platform Credential Manager] ---- 6. assertion ----> [Native App] [Native App] ---- 7. assertion JSON ----> [Backend] [Backend] ---- 8. verify WebAuthn, issue session ----> [Native App]
The key architecture point: the mobile app talks to the OS, not directly to a private key. The OS decides whether the app is allowed to request credentials for your RP ID by checking the app-domain association. If that association is wrong, the symptom is usually "no passkeys offered" or a generic platform error, even though your backend WebAuthn code is fine.
How it actually works
Walk one realistic example: an iOS or Android app for example.com signs in a returning user with a synced passkey.
Step 1: backend creates a WebAuthn authentication challenge
Your app calls something like POST /webauthn/authentication/options with a user hint or account identifier if you use conditional account selection. The backend returns standard WebAuthn request options:
challengerpId: "example.com"- allowed credentials, or empty for discoverable credentials
- timeout and user verification policy
The backend stores the challenge server-side, usually keyed to a login transaction ID with a short TTL.
Step 2: app asks the platform credential manager for a passkey assertion
The app passes the challenge and RP ID into the platform API. The OS checks whether the app is authorized to act for example.com.
That authorization is based on files served from your domain:
- iOS:
https://example.com/.well-known/apple-app-site-association - Android:
https://example.com/.well-known/assetlinks.json
The OS also checks app-side entitlements/manifest declarations.
If the domain association is valid, the credential manager can show passkeys for example.com. If not, the call often fails before the user ever sees a usable credential prompt.
Step 3: user verifies locally
The user authenticates with Face ID, Touch ID, device PIN, or equivalent. The private key never leaves the authenticator. The OS returns a signed assertion to the app.
Step 4: app sends assertion to backend
The app posts the assertion to POST /webauthn/authentication/verify. The backend verifies:
- challenge matches what it issued
- RP ID hash matches
example.com - origin / client data is acceptable for the platform flow you support
- signature verifies against the stored public key
- sign counter policy if you enforce it
- user handle / credential ID maps to an account
If valid, the backend issues the normal app session: cookie for embedded web, bearer token, or your standard mobile session token.
Step 5: fallback only when needed
If no passkey exists on this device, the app should offer a recovery path. Good fallback examples:
- sign in with another device using a QR or cross-device passkey flow
- email magic link to bootstrap a new passkey
- password only for legacy accounts, followed by immediate passkey enrollment
Bad fallback example:
- silently dropping to SMS OTP for everyone because passkeys are "optional"
That turns your strongest factor into a decorative feature while preserving the weakest account recovery path as the real auth system.
What breaks most often
The highest-frequency production issue is the association file path or response headers, not WebAuthn cryptography.
Typical checks:
curl -i https://example.com/.well-known/apple-app-site-association
Healthy shape:
HTTP/2 200
content-type: application/json
content-length: 214
cache-control: max-age=300
{"applinks":{"apps":[],"details":[{"appID":"ABCDE12345.com.example.app","paths":["*"]}]},"webcredentials":{"apps":["ABCDE12345.com.example.app"]}}
Misconfigured redirect shape:
HTTP/2 301
location: https://www.example.com/.well-known/apple-app-site-association
content-type: text/html
That redirect is enough to break association on some clients. Serve the file directly from the exact host you use as RP ID.
Android equivalent:
curl -i https://example.com/.well-known/assetlinks.json
Healthy shape:
HTTP/2 200
content-type: application/json
[{"relation":["delegate_permission/common.get_login_creds"],"target":{"namespace":"android_app","package_name":"com.example.app","sha256_cert_fingerprints":["12:34:...:AB"]}}]
If you rotate Android signing keys and forget to add the new SHA-256 fingerprint, passkeys can stop appearing after a release even though the package name is unchanged.
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
| Consumer mobile app with repeat sign-in and low tolerance for phishing/account takeover | Use passkeys as primary sign-in and keep a deliberate recovery flow. |
| Regulated app where device possession plus local biometric is acceptable | Strong fit, but define account recovery controls before launch. |
| Internal enterprise app with managed devices and SSO through an IdP app | Maybe. If your IdP already owns strong device-bound auth, passkeys in your app may add little. |
| App used once a year by low-engagement users | Use cautiously. Recovery and re-enrollment volume may dominate the benefit. |
| You do not control a stable web domain for the RP ID | Don’t start yet. Fix domain ownership and HTTPS first. |
| You still rely on SMS OTP for recovery with no fraud controls | Don’t call the system passwordless yet; harden recovery first. |
| You need broad support for old OS versions | Keep fallback sign-in. Passkeys can be additive, not exclusive. |
You probably don’t need this if your app is basically a thin client over an existing enterprise identity stack and your users already authenticate through a managed browser or IdP-native app with phishing-resistant MFA.
Trade-offs
Benefit and cost come together here.
- Phishing resistance → costs you domain/app association work and stricter backend validation. You cannot fake this with just a mobile SDK.
- Better UX than passwords → costs you recovery design. Users still lose devices, reinstall apps, and switch ecosystems.
- Credential sync across devices → costs you less direct control over credential lifecycle. Platform ecosystems mediate storage and sync behavior.
- Lower password reset volume → costs you more nuanced support cases like "credential exists on old phone but not new work profile".
- Strong local user verification → costs you platform-specific edge-case handling for biometrics disabled, no screen lock, or enterprise policy restrictions.
- Standards-based WebAuthn backend → costs you care around RP ID/origin consistency across app, web, staging, and regional domains.
Operationally, the biggest hidden cost is environment sprawl. If you have dev.example.net, staging.example.net, and example.com, each environment needs its own valid HTTPS hosting, association files, and often separate app identifiers or build flavors. Teams often get passkeys working in production first and then discover staging is impossible to test because the RP ID and app association setup were never designed for multiple environments.
In practice
Serve iOS association file directly with nginx
server {
listen 443 ssl http2;
server_name example.com;
location = /.well-known/apple-app-site-association {
default_type application/json;
add_header Cache-Control "max-age=300" always;
return 200 '{"applinks":{"apps":[],"details":[{"appID":"ABCDE12345.com.example.app","paths":["*"]}]},"webcredentials":{"apps":["ABCDE12345.com.example.app"]}}';
}
location = /.well-known/assetlinks.json {
default_type application/json;
add_header Cache-Control "max-age=300" always;
return 200 '[{"relation":["delegate_permission/common.get_login_creds"],"target":{"namespace":"android_app","package_name":"com.example.app","sha256_cert_fingerprints":["12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF"]}}]';
}
}
This serves both association files from the exact required paths with a 200 and JSON content type. Gotcha: do not put these behind auth, HTML error pages, or canonical-host redirects; the OS fetcher is less forgiving than a browser.
Verify the domain endpoints in CI
set -euo pipefail
check_url() {
local url="$1"
echo "Checking $url"
headers=$(curl -sS -D - -o /tmp/body.$$ "$url")
echo "$headers" | grep -qE '^HTTP/[12](\.[0-9])? 200' || {
echo "ERROR: non-200 response for $url" >&2
echo "$headers" >&2
exit 1
}
echo "$headers" | grep -qi '^content-type: application/json' || {
echo "ERROR: content-type is not application/json for $url" >&2
echo "$headers" >&2
exit 1
}
}
check_url "https://example.com/.well-known/apple-app-site-association"
check_url "https://example.com/.well-known/assetlinks.json"
echo "Association endpoints look sane"
This catches the common breakages introduced by CDN rules, host redirects, and static-site pipelines. Gotcha: this only checks transport and headers; it does not validate that the app IDs, team ID, package name, or certificate fingerprints are correct.
Backend verification policy sketch
{
"rpId": "example.com",
"expectedOrigins": [
"https://example.com",
"android:apk-key-hash:BASE64URL_FINGERPRINT_HASH",
"ios:bundle-id:com.example.app"
],
"userVerification": "required",
"allowUnregisteredUsers": false,
"challengeTtlSeconds": 300
}
This is not a universal library format; it is a policy sketch showing the values you need to pin down. Gotcha: many teams are strict about rpId but vague about accepted origins, then accidentally reject valid mobile assertions or accept more origins than intended.
⚠️ If you change the RP ID in production, existing passkeys for the old RP ID will not authenticate against the new one. Treat RP ID changes like a credential migration, not a harmless config edit.
For fallback sign-in, keep the decision tree explicit:
- Try passkey first.
- If no credential is available, offer cross-device sign-in or email magic link.
- If you still support passwords, use them only to bootstrap a new passkey and log that the account is in legacy mode.
- Do not auto-fallback to SMS unless your threat model truly accepts SIM-swap risk.
Further reading
- Web Authentication: An API for Accessing Public Key Credentials Level 3
- Apple Developer Documentation: Supporting Associated Domains
- Android Developers: Verify Android App Links
- FIDO Alliance: Passkey UX Guidelines
- MDN Web Docs: Web Authentication API
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI