React Native Passkeys with Expo (2026): WebAuthn, Associated Domains, and Credential Manager

Ship WebAuthn passkeys in React Native with Expo dev-builds, Associated Domains on iOS, Credential Manager on Android, and server-side verification.

Updated: August 23, 2026

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.

If you've shipped React Native authentication with Expo secure storage and biometrics before, the mental model is similar. The OS holds the private key material, your JavaScript never sees it, and the server verifies a signature over a challenge.

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.

LibraryVersioniOSAndroidExpo dev-buildSignal APIs
react-native-passkey (f-23)3.6.115+API 28+ (Credential Manager 1.6.0)Yes (config plugin)Yes
react-native-passkeys (peterferguson)0.5.x16+API 28+Yes (Expo module)Partial
@clerk/expo-passkeys1.1.016+API 28+Yes (Expo >=53 <57)Yes (via Clerk)
expo-passkey (iosazee)0.4.x16+API 28+YesNo

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.

npx create-expo-app@latest passkeys-demo --template default
cd passkeys-demo
npx expo install react-native-passkey expo-build-properties
npm install --save @simplewebauthn/server @simplewebauthn/browser

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.

{
  "expo": {
    "scheme": "acme",
    "ios": {
      "bundleIdentifier": "com.acme.wallet",
      "associatedDomains": ["webcredentials:auth.acme.com"]
    },
    "android": {
      "package": "com.acme.wallet"
    },
    "plugins": [
      "react-native-passkey",
      ["expo-build-properties", {
        "ios": { "deploymentTarget": "17.5" },
        "android": { "compileSdkVersion": 35, "targetSdkVersion": 35 }
      }]
    ]
  }
}

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.

{
  "webcredentials": {
    "apps": ["TEAMID1234.com.acme.wallet"]
  }
}

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.

[{
  "relation": [
    "delegate_permission/common.get_login_creds",
    "delegate_permission/common.handle_all_urls"
  ],
  "target": {
    "namespace": "android_app",
    "package_name": "com.acme.wallet",
    "sha256_cert_fingerprints": [
      "AA:BB:CC:DD:EE:FF:...:11:22"
    ]
  }
}]

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.

// server/registration.ts
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";

const rpID = "auth.acme.com";
const rpName = "Acme Wallet";

export async function beginRegistration(userId: string, email: string) {
  const options = await generateRegistrationOptions({
    rpID,
    rpName,
    userName: email,
    userID: new TextEncoder().encode(userId),
    attestationType: "none",
    authenticatorSelection: {
      residentKey: "required",
      userVerification: "required",
    },
    supportedAlgorithmIDs: [-7, -257],
  });
  await saveChallenge(userId, options.challenge);
  return options;
}

export async function finishRegistration(userId: string, body: unknown) {
  const expectedChallenge = await popChallenge(userId);
  const verification = await verifyRegistrationResponse({
    response: body as any,
    expectedChallenge,
    expectedOrigin: [`https://${rpID}`, `android:apk-key-hash:...`],
    expectedRPID: rpID,
    requireUserVerification: true,
  });
  if (!verification.verified || !verification.registrationInfo) {
    throw new Error("Registration failed verification");
  }
  await saveCredential(userId, verification.registrationInfo);
  return { ok: true };
}

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.

// app/register.ts
import { Passkey, PasskeyRegistrationRequest } from "react-native-passkey";

export async function registerPasskey(email: string) {
  const opts = await api.post<PasskeyRegistrationRequest>("/webauthn/begin", { email });
  const credential = await Passkey.register(opts);
  const result = await api.post("/webauthn/finish", credential);
  return result;
}

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.

// app/login.ts
import { Passkey } from "react-native-passkey";

export async function signInWithPasskey() {
  const opts = await api.post("/webauthn/login/begin", {});
  const assertion = await Passkey.authenticate(opts);
  const session = await api.post("/webauthn/login/finish", assertion);
  return session;
}

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.

const verified = await verifyAuthenticationResponse({
  response: body,
  expectedChallenge,
  expectedOrigin: [`https://${rpID}`, "android:apk-key-hash:..."],
  expectedRPID: rpID,
  credential: {
    id: stored.credentialID,
    publicKey: stored.publicKey,
    counter: stored.counter,
    transports: stored.transports,
  },
  requireUserVerification: true,
});

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.

Yelena Petrov
About the Author Yelena Petrov

React Native architect at a fintech. Builds platform teams, type-safe bridges, and runs the upgrade playbook so others don't have to.