Yes, React Native supports passkeys in 2026, but only in an Expo dev-build (not Expo Go), and you have to wire up Associated Domains on iOS, an assetlinks.json file on Android, and a WebAuthn-compliant relying party (RP) on your backend. This is the library-agnostic playbook I use to ship WebAuthn Level 3 credentials in fintech apps: registration, assertion, error-handling, and the config-plugin scaffolding that survives an Expo SDK upgrade.
Use react-native-passkey (v3.6.1, Aug 2026) or @clerk/expo-passkeys. Both need an Expo dev-build; passkeys don't work in Expo Go or on Android emulators.
Server-side WebAuthn verification with @simplewebauthn/server is non-negotiable. Never trust the client-side PublicKeyCredential.
iOS needs webcredentials:<domain> in Associated Domains plus an apple-app-site-association file. Android needs an assetlinks.json with your SHA-256 signing fingerprint.
iOS 26 adds cross-platform passkey import/export; Android 15+ ships Credential Manager androidx.credentials 1.6.0 with signalUnknownCredential for revocation.
Expo SDK 55 mandates the New Architecture, so verify your passkey library is New-Arch compatible before upgrading.
The most common production bug is a stale assetlinks.json cached by Google Play services for up to 24 hours after redeploy.
Does React Native support passkeys?
React Native supports passkeys through native bridges to Apple's AuthenticationServices framework on iOS and Android's Credential Manager API on Android. There's no cross-platform JavaScript polyfill for navigator.credentials.create() in React Native. You always call a native module that wraps ASAuthorizationPlatformPublicKeyCredentialProvider or androidx.credentials.CredentialManager.
What that means in practice: you install a library, generate a dev-build, and treat the passkey call as an async native operation that returns a JSON blob shaped like the W3C PublicKeyCredential interface. Your backend then verifies the attestation (registration) or assertion (login) against a challenge you issued.
Two firm constraints in 2026: passkeys don't work in Expo Go (the sandbox doesn't carry your associated-domain entitlement), and they don't work on Android emulators without Google Play Services and a screen lock configured. Every serious integration ships from an EAS dev-build against a physical device.
Passkey library landscape in 2026
The React Native passkey ecosystem consolidated in the last twelve months. @passageidentity/passage-react-native was deprecated in January 2026, and the community settled on three viable choices: react-native-passkey for a bare-metal wrapper, react-native-passkeys for an Expo-module port of the navigator.credentials API, and @clerk/expo-passkeys if you already use Clerk as your identity provider.
Library
Version
iOS
Android
Expo dev-build
Signal APIs
react-native-passkey (f-23)
3.6.1
15+
API 28+ (Credential Manager 1.6.0)
Yes (config plugin)
Yes
react-native-passkeys (peterferguson)
0.5.x
16+
API 28+
Yes (Expo module)
Partial
@clerk/expo-passkeys
1.1.0
16+
API 28+
Yes (Expo >=53 <57)
Yes (via Clerk)
expo-passkey (iosazee)
0.4.x
16+
API 28+
Yes
No
Honestly, I pick react-native-passkey for greenfield fintech apps because the API surface stays close to the WebAuthn spec, the maintainer ships a config plugin that generates the AutoFill entitlement, and it exposes the newer signalUnknownCredential and signalAllAcceptedCredentials hooks introduced in androidx.credentials 1.6.0. Those signal APIs are the mechanism you use to revoke a credential from the Password Manager after a user rotates or deletes it server-side. Without them, a rotated key stays visible in the OS credential picker forever (yes, forever, until the user manually clears it).
Project setup: Expo dev-build and dependencies
Start from a fresh Expo project on SDK 54 or 55. If you're already on SDK 55, confirm your passkey library declares New Architecture support in its package.json under codegenConfig. Then install the library, its Expo config plugin, and a WebAuthn-compatible backend helper.
Register the config plugin and the entitlements in app.json. The associatedDomains array is what triggers the OS to fetch your apple-app-site-association file when the app installs.
Generate the dev-client and install it on a physical device or a paid iCloud simulator. Passkey libraries all fail hard on Expo Go because the sandbox bundle identifier host.exp.Exponent isn't what your RP ID trusts.
npx expo prebuild --clean
eas build --profile development --platform ios
eas build --profile development --platform android
iOS: Associated Domains and apple-app-site-association
Apple binds a passkey to a bundle identifier and a domain through the Associated Domains entitlement. Without a valid AASA file at the exact path https://<domain>/.well-known/apple-app-site-association, iOS silently refuses to surface any credential. The file must be served over HTTPS, must return a 200 with Content-Type: application/json, and must not include a redirect.
Replace TEAMID1234 with your Apple Developer team identifier and use the exact bundle identifier from app.json. Apple's CDN caches AASA aggressively (up to 24 hours in production and about 1 hour on TestFlight). To force a re-fetch during development, delete and reinstall the app; on TestFlight, wait for the CDN to expire or bump the app version.
Verify the association from the device by tailing the console (xcrun simctl spawn booted log stream --predicate 'subsystem == "com.apple.AuthenticationServices"'). If you see Domain does not have any credentials configured, the AASA is either unreachable, malformed, or missing the exact bundle-team pair. When you finally see PublicKeyCredential created, you can move to the Android side. I hit this exact log-tail workflow shipping a wallet app last spring and it saved me hours of guessing.
Android: Credential Manager and assetlinks.json
Android uses Digital Asset Links. Publish an assetlinks.json at https://<domain>/.well-known/assetlinks.json that lists the SHA-256 fingerprint of each keystore you sign with: debug, upload, and Play App Signing. If you use EAS, the SHA-256 you need is the one Play Console shows under Setup → App integrity → App signing key certificate, not the upload key.
Google Play Services caches this file for up to 24 hours. During development, uninstall and reinstall the app, then confirm using the assetlinks tester at https://developers.google.com/digital-asset-links/tools/generator. Note that the same domain hosts both the AASA (no .json extension) and the assetlinks file (with .json). It's one of the deep-linking overlaps I cover in our deep linking with Expo Router guide.
Registration flow: attestation with a real RP
Registration binds a new credential to the user's account. The client requests a challenge from the RP, the OS creates a keypair and signs the challenge, and the RP verifies the resulting attestation. Never accept a passkey the client claims to have registered without server verification. The client-side PublicKeyCredential is a hint, not proof.
On the client, hand those options straight to the native module. react-native-passkey takes a JSON-serialized options object and returns a PublicKeyCredential-shaped response.
Set residentKey: "required" so the credential is discoverable, which is what enables usernameless sign-in and conditional UI later. Use attestationType: "none" unless you have a compliance reason to demand a specific attestation format. Requesting attestation on iOS forces an extra prompt and rejects on many corporate-managed devices.
Authentication flow: assertion and conditional UI
Authentication is the same shape in reverse: issue a challenge, let the OS sign it, verify the assertion server-side. If you enrolled resident keys, you can offer a passwordless flow where the OS surfaces the available accounts in a system sheet.
On the server, verify with verifyAuthenticationResponse and rotate the signature counter to defend against cloned authenticators. Modern platform authenticators like iCloud Keychain sync the private key across devices and always return counter = 0. The FIDO Alliance guidance for 2026 is to skip the counter check entirely for backup-eligible credentials and rely on the BE and BS flags instead. Store both flags in your credential row so you can decide per credential whether to enforce the counter.
Conditional UI (mediation: "conditional"), the AutoFill-style hint that offers a passkey inside a normal login form, is still limited on React Native. react-native-passkey 3.6.1 exposes it on iOS 17+ but not yet on Android. For an app-native flow this is usually fine: show a big "Sign in with a passkey" button and call Passkey.authenticate() when tapped.
Error-handling matrix for React Native passkeys
Passkey errors bubble up as strings from the native layer, and they're not particularly self-descriptive. Build a small mapping so your UI surfaces the right message and your telemetry can distinguish user cancellation from genuine misconfiguration. The two look identical if you only log the error name.
type PasskeyErrorCode =
| "UserCancelled"
| "NoCredentials"
| "DomainMismatch"
| "NoBiometricsEnrolled"
| "NetworkFailure"
| "UnknownAuthenticator";
export function mapPasskeyError(err: unknown): PasskeyErrorCode {
const name = (err as { name?: string }).name ?? "";
const message = (err as { message?: string }).message ?? "";
if (name === "UserCancelled" || /canceled/i.test(message)) return "UserCancelled";
if (/no.*credential/i.test(message)) return "NoCredentials";
if (/domain|origin|rpId/i.test(message)) return "DomainMismatch";
if (/no.*biometric|screen lock/i.test(message)) return "NoBiometricsEnrolled";
if (/network|timeout/i.test(message)) return "NetworkFailure";
return "UnknownAuthenticator";
}
Route DomainMismatch to an ops alert. It almost always means your AASA or assetlinks file drifted after a deploy. Route NoCredentials to a graceful fallback (offer the user a password login or a magic link) and, if you know they registered a credential on another device, prompt for cross-device auth via QR code. Route UserCancelled silently. It's not a bug, and dashboards that count it as one waste engineering time.
Passkeys vs biometrics: what actually differs
Biometrics (Face ID, Touch ID, Android BiometricPrompt) are a local user verification gesture. A passkey is a public-private keypair stored in the OS keychain that the biometric gesture unlocks. In other words, biometrics authenticate the user to the device; passkeys authenticate the device to your server. Shipping expo-local-authentication alone gives you a good-looking prompt but no cryptographic proof at your backend. Anyone who steals the session cookie still gets in.
A passkey solves that: even if a phishing site tricks the user, the browser or OS refuses to sign a challenge for a domain that doesn't match the credential's RP ID. Combined with server-side WebAuthn verification, you get phishing-resistant, replay-resistant, credential-stuffing-resistant auth in a single tap. That's why the FIDO Alliance's 2025 adoption report shows Google's passkey sign-in success rate is 4x that of passwords, and TikTok's is 97%. The flow simply fails less often than typing a password.
Practically speaking: use passkeys as the primary factor and keep biometrics as a step-up for high-risk transactions like transfers or profile changes. If you want the deep dive on the biometric side, see our authentication with secure storage and biometrics guide.
Frequently Asked Questions
Do passkeys work in Expo Go?
No. Passkeys require your app's bundle identifier to match the entry in apple-app-site-association or assetlinks.json, and Expo Go ships as host.exp.Exponent. You must build an Expo dev-client with eas build --profile development and install it on a physical device.
Can passkeys work on Android emulators?
Only on Google Play system images with Google Play Services enabled and a screen lock configured. Even then, credentials don't sync to the Password Manager reliably. For real testing you need a physical Android device running Android 9+ (API 28) with a screen lock and a Google account signed in.
Why does my Android passkey return "no matching credentials"?
Almost always a stale or misconfigured assetlinks.json. Confirm the SHA-256 fingerprint matches the keystore your APK was signed with (usually the Play App Signing key, not your upload key), that the file is reachable over HTTPS with no redirect, and then uninstall and reinstall the app so Google Play Services fetches the fresh copy.
Passkeys vs OAuth: which is more secure?
They solve different problems. OAuth is delegated authorization; passkeys are direct authentication. In practice, using an OAuth provider that itself uses passkeys under the hood gives you the phishing resistance of WebAuthn plus a federated identity, but a first-party passkey implementation avoids depending on a third-party outage.
Do I still need @simplewebauthn/server if I use react-native-passkey?
Yes. The React Native library only wraps the OS native APIs on the client. You still need a WebAuthn-compliant server to issue challenges and verify attestation and assertion responses. @simplewebauthn/server is the de-facto Node.js implementation, but any RFC-compliant library works. The format on the wire is the W3C PublicKeyCredential JSON.
Capture heap snapshots from a Hermes-powered React Native app in Expo, read retainer paths in Chrome DevTools, and catch the three most common leaks with numbers, traces, and CI regression tests.
Ship native Apple Maps and Google Maps in a React Native app with expo-maps: install the config plugin, render your first map, drop markers, handle camera events, and decide when to stay on react-native-maps.
Ship iOS home screen widgets from an Expo React Native app using @bacons/apple-targets. Covers App Groups, SwiftUI layouts, App Intents on iOS 17+, deep-linking, and the debugging traps that eat weekends.