# Quickstart with Passkey recovery

Build a functional authentication and wallet sample app using the Openfort React Native SDK with biometric passkey recovery.
This guide assumes you've completed the [Getting Started guide](/docs/products/embedded-wallet/react-native) and have your `OpenfortProvider` configured.

In this guide, we are going to help you set up your wallet recovery method to our `Passkey` method.
These are our wallet recovery methods you can choose from:

* [Switch to Automatic Recovery](/docs/products/embedded-wallet/react-native/quickstart/automatic)
* [Switch to Password Recovery](/docs/products/embedded-wallet/react-native/quickstart/password)
* **Using Passkey Recovery**

Not sure what wallet recovery method you need? Don't miss [our guide](/docs/configuration/recovery-methods).

:::info\[About passkeys]
Passkeys let users authenticate with device biometrics (Face ID, Touch ID, fingerprint). Keys are stored in the device's secure enclave and sync across devices via iCloud Keychain or Google Password Manager.
:::

:::warning
Passkey recovery requires a **native build**. It is not available in Expo Go. You must run `npx expo prebuild` and build natively.
:::

:::warning\[Domain-bound credentials]
Passkey credentials are bound to a specific domain (the Relying Party). Credentials created for your domain (e.g., `example.com`) only work on that domain—they cannot be used on other domains.

**This means users cannot use the same passkey wallet on other applications.** If your product spans multiple domains, consider using [automatic recovery](/docs/products/embedded-wallet/react-native/quickstart/automatic) instead.
:::

::::::steps

## Get your Openfort keys

From this point on, you'll need an [Openfort account](https://dashboard.openfort.io/auth/register) to handle project keys and settings.

In the [Openfort Dashboard](https://dashboard.openfort.io), select your desired app and navigate to the [API keys](https://dashboard.openfort.io/api-keys).
There, you'll get the following keys:

**Project keys**: Required for any wallet.

| Key | Exposure | Description |
| --- | --- | --- |
| **Publishable key** | Public (client) | Initialize the SDK (in `OpenfortProvider`). |
| **Secret key** | Secret (server) | Privileged backend actions (sessions, wallet management). Never bundle in the app or commit to git. |

**Shield Keys**: Required for non-custodial wallets.

| Key | Exposure | Description |
| --- | --- | --- |
| **Publishable key** | Public (client) | Public key the app uses to enable non‑custodial wallet features. Safe to include in client code. |

Get the **Project Publishable Key** and the **Shield Publishable Key** and save them for the configuration steps below.

:::warning
A properly set up publishable key is required for mobile apps and other non-web platforms to allow your app to interact with the Openfort API. Please follow [this guide](/docs/configuration/native-apps) to configure an app client.
:::

## Install passkey dependencies

Install the required passkey library:

```bash
npm install react-native-passkeys
```

Then run prebuild to generate the native code:

```bash
npx expo prebuild --clean
```

## Set up domain verification

Passkeys require HTTPS domain verification. For local development, use ngrok or a similar tunneling service.

### Start ngrok tunnel

```bash
# In a separate terminal
ngrok http 8081
```

Save the ngrok URL (e.g., `abc123.ngrok-free.app`) — this will be your **Relying Party ID** (RP ID).

### Configure environment variables

```bash
# .env
# Publishable keys
OPENFORT_PROJECT_PUBLISHABLE_KEY=YOUR_PROJECT_PUBLISHABLE_KEY
OPENFORT_SHIELD_PUBLISHABLE_KEY=YOUR_SHIELD_PUBLISHABLE_KEY

# Passkey configuration
PASSKEY_RP_ID=abc123.ngrok-free.app
PASSKEY_RP_NAME=Your App Name
PASSKEY_DISPLAY_NAME=My Wallet
```

## Platform setup

Choose your target platform and follow the setup instructions.

<MultiOptionDisplay
  options={[
  { id: 'ios', label: 'iOS' },
  { id: 'android', label: 'Android' },
]}
/>

<span id="ios" className="hidden [&>*]:mb-6!">
  iOS uses **Associated Domains** to verify your app can use passkeys for a domain.

  ### 1. Get your Apple credentials

  | Credential | Where to find it |
  | --- | --- |
  | **Team ID** | [Apple Developer Portal](https://developer.apple.com/account) → Membership |
  | **Bundle ID** | Your app's bundle identifier (e.g., `com.yourcompany.yourapp`) |

  ### 2. Add environment variables

  ```bash
  # .env (add to existing)
  APPLE_TEAM_ID=YOUR_APPLE_TEAM_ID
  APPLE_BUNDLE_ID=com.yourcompany.yourapp
  ```

  ### 3. Configure app.config.js

  ```js
  // app.config.js
  export default {
    expo: {
      name: "openfort-passkey-sample",
      slug: "openfort-passkey-sample",
      version: "1.0.0",
      platforms: ["ios"],
      ios: {
        bundleIdentifier: process.env.APPLE_BUNDLE_ID,
        associatedDomains: [
          `webcredentials:${process.env.PASSKEY_RP_ID}`
        ],
      },
      extra: {
        openfortPublishableKey: process.env.OPENFORT_PROJECT_PUBLISHABLE_KEY,
        openfortShieldPublishableKey: process.env.OPENFORT_SHIELD_PUBLISHABLE_KEY,
        passkeyRpId: process.env.PASSKEY_RP_ID,
        passkeyRpName: process.env.PASSKEY_RP_NAME || "Openfort Wallet",
        passkeyDisplayName: process.env.PASSKEY_DISPLAY_NAME || "Openfort - Embedded Wallet",
      },
    },
  };
  ```

  ### 4. Create the Apple App Site Association endpoint

  Create an API route to serve the domain verification file:

  ```ts
  // app/.well-known/apple-app-site-association+api.ts
  export function GET() {
    const teamId = process.env.APPLE_TEAM_ID;
    const bundleId = process.env.APPLE_BUNDLE_ID;

    if (!teamId || !bundleId) {
      return Response.json({ error: "Missing Apple credentials" }, { status: 500 });
    }

    return Response.json({
      webcredentials: {
        apps: [`${teamId}.${bundleId}`],
      },
    }, {
      headers: { "Content-Type": "application/json" },
    });
  }
  ```

  ### 5. Verify the endpoint

  ```bash
  curl https://your-subdomain.ngrok-free.app/.well-known/apple-app-site-association
  ```

  Expected response:

  ```json
  {
    "webcredentials": {
      "apps": ["TEAM_ID.com.yourcompany.yourapp"]
    }
  }
  ```
</span>

<span id="android" className="hidden [&>*]:mb-6!">
  Android uses **Digital Asset Links** to verify your app can use passkeys for a domain.

  ### 1. Get your SHA256 fingerprint

  Run this command to get your debug keystore fingerprint:

  ```bash
  keytool -list -v -keystore ~/.android/debug.keystore \
    -alias androiddebugkey -storepass android | grep SHA256
  ```

  For production, use your release keystore fingerprint.

  ### 2. Add environment variables

  ```bash
  # .env (add to existing)
  ANDROID_PACKAGE_NAME=com.yourcompany.yourapp
  ANDROID_SHA256_FINGERPRINTS=FA:C6:17:45:DC:09:03:78:...
  ```

  :::tip
  You can add multiple fingerprints (comma-separated) for debug and release builds.
  :::

  ### 3. Configure app.config.js

  ```js
  // app.config.js
  export default {
    expo: {
      name: "openfort-passkey-sample",
      slug: "openfort-passkey-sample",
      version: "1.0.0",
      platforms: ["android"],
      android: {
        package: process.env.ANDROID_PACKAGE_NAME,
      },
      extra: {
        openfortPublishableKey: process.env.OPENFORT_PROJECT_PUBLISHABLE_KEY,
        openfortShieldPublishableKey: process.env.OPENFORT_SHIELD_PUBLISHABLE_KEY,
        passkeyRpId: process.env.PASSKEY_RP_ID,
        passkeyRpName: process.env.PASSKEY_RP_NAME || "Openfort Wallet",
        passkeyDisplayName: process.env.PASSKEY_DISPLAY_NAME || "Openfort - Embedded Wallet",
      },
    },
  };
  ```

  ### 4. Create the Digital Asset Links endpoint

  Create an API route to serve the domain verification file:

  ```ts
  // app/.well-known/assetlinks.json+api.ts
  export function GET() {
    const packageName = process.env.ANDROID_PACKAGE_NAME;
    const sha256Fingerprints = process.env.ANDROID_SHA256_FINGERPRINTS;

    if (!packageName || !sha256Fingerprints) {
      return Response.json({ error: "Missing Android credentials" }, { status: 500 });
    }

    const fingerprints = sha256Fingerprints.split(",").map((f) => f.trim());

    return Response.json([
      {
        relation: [
          "delegate_permission/common.handle_all_urls",
          "delegate_permission/common.get_login_creds"
        ],
        target: {
          namespace: "android_app",
          package_name: packageName,
          sha256_cert_fingerprints: fingerprints,
        },
      },
    ], {
      headers: { "Content-Type": "application/json" },
    });
  }
  ```

  ### 5. Verify the endpoint

  ```bash
  curl https://your-subdomain.ngrok-free.app/.well-known/assetlinks.json
  ```

  Expected response:

  ```json
  [{
    "relation": ["delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.yourcompany.yourapp",
      "sha256_cert_fingerprints": ["FA:C6:17:45:..."]
    }
  }]
  ```
</span>

:::tip\[Both platforms]
If you're targeting both platforms, follow both the iOS and Android setup instructions above. Use both `ios` and `android` sections in your `app.config.js`, set `platforms: ["ios", "android"]`, and create both well-known endpoints (`apple-app-site-association` and `assetlinks.json`).
:::

## Configure OpenfortProvider with passkey

Replace your basic provider setup with passkey configuration:

:::code-group

```tsx [With expo-router]
// app/_layout.tsx
import { OpenfortProvider, RecoveryMethod } from "@openfort/react-native";
import Constants from "expo-constants";
import { Stack } from "expo-router";

const {
  openfortPublishableKey,
  openfortShieldPublishableKey,
  passkeyRpId,
  passkeyRpName,
  passkeyDisplayName,
} = Constants.expoConfig?.extra ?? {};

export default function RootLayout() {
  return (
    <OpenfortProvider
      publishableKey={openfortPublishableKey}
      walletConfig={{
        debug: false,
        recoveryMethod: RecoveryMethod.PASSKEY,
        shieldPublishableKey: openfortShieldPublishableKey,
        passkeyRpId,
        passkeyRpName,
        passkeyDisplayName,
      }}
      verbose={true}
      supportedChains={[
        {
          id: 84532,
          name: 'Base Sepolia',
          nativeCurrency: {
            name: 'Base Sepolia Ether',
            symbol: 'ETH',
            decimals: 18
          },
          rpcUrls: { default: { http: ['https://sepolia.base.org'] } },
        },
      ]}
    >
      <Stack>
        <Stack.Screen name="index" />
      </Stack>
    </OpenfortProvider>
  );
}
```

```tsx [Without expo-router]
// App.tsx
import { OpenfortProvider, RecoveryMethod } from "@openfort/react-native";
import Constants from "expo-constants";
import MainApp from "./MainApp";

const {
  openfortPublishableKey,
  openfortShieldPublishableKey,
  passkeyRpId,
  passkeyRpName,
  passkeyDisplayName,
} = Constants.expoConfig?.extra ?? {};

export default function App() {
  return (
    <OpenfortProvider
      publishableKey={openfortPublishableKey}
      walletConfig={{
        debug: false,
        recoveryMethod: RecoveryMethod.PASSKEY,
        shieldPublishableKey: openfortShieldPublishableKey,
        passkeyRpId,
        passkeyRpName,
        passkeyDisplayName,
      }}
      verbose={true}
      supportedChains={[
        {
          id: 84532,
          name: 'Base Sepolia',
          nativeCurrency: {
            name: 'Base Sepolia Ether',
            symbol: 'ETH',
            decimals: 18
          },
          rpcUrls: { default: { http: ['https://sepolia.base.org'] } },
        },
      ]}
    >
      <MainApp />
    </OpenfortProvider>
  );
}
```

:::

## Create the login and user screens

Use `usePasskeyPrfSupport` to gate passkey UI: show a loading state while checking support, display a message when passkeys are unavailable, and disable the "Create Wallet with Passkey" button when not supported. Create components that support passkey wallet creation and recovery:

:::code-group

```tsx [components/LoginScreen.tsx]
// components/LoginScreen.tsx
import { OAuthProvider, useGuestAuth, useOAuth, usePasskeyPrfSupport } from "@openfort/react-native";
import { useEffect } from "react";
import { Button, Text, View, StyleSheet, ActivityIndicator } from "react-native";

export default function LoginScreen() {
  const { signUpGuest } = useGuestAuth();
  const { initOAuth, error: authError } = useOAuth();
  const { isSupported: passkeySupported, isLoading: passkeyLoading } = usePasskeyPrfSupport();

  useEffect(() => {
    if (authError) {
      console.error("[Openfort RN] Error logging in with OAuth:", authError);
    }
  }, [authError]);

  if (passkeyLoading) {
    return (
      <View style={styles.container}>
        <ActivityIndicator size="large" />
        <Text>Checking passkey support...</Text>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Openfort Passkey Example</Text>

      {passkeySupported ? (
        <Text style={styles.supportText}>Passkeys are supported on this device</Text>
      ) : (
        <Text style={styles.warningText}>Passkeys are not supported on this device</Text>
      )}

      <Button title="Login as Guest" onPress={() => signUpGuest()} />

      <View style={styles.providersContainer}>
        {[OAuthProvider.Google, OAuthProvider.Apple].map((provider) => (
          <View key={provider.toString()}>
            <Button
              title={`Login with ${provider.toString()}`}
              onPress={() => initOAuth({ provider })}
            />
          </View>
        ))}
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
    gap: 10,
    marginHorizontal: 10,
  },
  title: {
    fontSize: 20,
    fontWeight: "bold",
  },
  supportText: {
    color: "green",
    marginBottom: 10,
  },
  warningText: {
    color: "orange",
    marginBottom: 10,
  },
  providersContainer: {
    display: "flex",
    flexDirection: "column",
    gap: 5,
    margin: 10,
  },
});
```

```tsx [components/UserScreen.tsx]
// components/UserScreen.tsx
import { useCallback } from "react";
import { Alert, Button, ScrollView, Text, View, StyleSheet, ActivityIndicator } from "react-native";
import { useUser, useEmbeddedEthereumWallet, useSignOut, usePasskeyPrfSupport } from "@openfort/react-native";

export const UserScreen = () => {
  const { user } = useUser();
  const { signOut } = useSignOut();
  const { isSupported: passkeySupported } = usePasskeyPrfSupport();
  const ethereum = useEmbeddedEthereumWallet();

  const activeWallet = ethereum.status === 'connected' ? ethereum.activeWallet : null;

  const handleCreateWalletWithPasskey = useCallback(async () => {
    if (!passkeySupported) {
      Alert.alert("Error", "Passkeys are not supported on this device");
      return;
    }

    try {
      await ethereum.create({
        recoveryMethod: 'passkey',
        onSuccess: ({ account }) => {
          Alert.alert("Success", `Wallet created with passkey: ${account?.address}`);
        },
        onError: (error) => {
          Alert.alert("Error", error.message);
        },
      });
    } catch (error) {
      console.error("Error creating wallet with passkey", error);
    }
  }, [ethereum, passkeySupported]);

  const handleConnectWallet = useCallback(async (wallet) => {
    try {
      await ethereum.setActive({
        address: wallet.address,
        chainId: 84532,
        onSuccess: () => {
          Alert.alert("Success", "Wallet connected");
        },
        onError: (error) => {
          Alert.alert("Error", error.message);
        },
      });
    } catch (error) {
      console.error("Error connecting wallet", error);
    }
  }, [ethereum]);

  const handleSignMessage = useCallback(async () => {
    if (ethereum.status !== 'connected') {
      Alert.alert("Error", "Create a wallet first");
      return;
    }

    try {
      const result = await ethereum.provider.request({
        method: "personal_sign",
        params: ["Hello from Openfort with Passkey!", ethereum.activeWallet.address],
      });
      Alert.alert("Message signed", String(result));
    } catch (e) {
      console.error("Error signing message", e);
      Alert.alert("Error", "Failed to sign message");
    }
  }, [ethereum]);

  if (!user) return null;

  return (
    <ScrollView contentContainerStyle={styles.container}>
      <View style={styles.section}>
        <Text style={styles.label}>User ID</Text>
        <Text>{user.id}</Text>
      </View>

      <View style={styles.section}>
        <Text style={styles.label}>Passkey Support</Text>
        <Text>{passkeySupported ? "Supported" : "Not Supported"}</Text>
      </View>

      <View style={styles.section}>
        <Text style={styles.label}>Active Wallet</Text>
        <Text>{activeWallet?.address ?? "No wallet connected"}</Text>
        {activeWallet?.recoveryMethod && (
          <Text style={styles.subText}>Recovery: {activeWallet.recoveryMethod}</Text>
        )}
      </View>

      {ethereum.status === 'creating' || ethereum.status === 'connecting' ? (
        <ActivityIndicator size="large" />
      ) : (
        <>
          {!activeWallet && ethereum.wallets.length === 0 && (
            <View style={styles.buttonWrap}>
              <Button
                title="Create Wallet with Passkey"
                onPress={handleCreateWalletWithPasskey}
                disabled={!passkeySupported}
              />
            </View>
          )}

          {!activeWallet && ethereum.wallets.length > 0 && (
            <View style={styles.section}>
              <Text style={styles.label}>Your Wallets</Text>
              {ethereum.wallets.map((wallet) => (
                <View key={wallet.address} style={styles.walletItem}>
                  <Text numberOfLines={1}>{wallet.address}</Text>
                  <Text style={styles.subText}>
                    {wallet.recoveryMethod === 'passkey' ? 'Passkey' : wallet.recoveryMethod}
                  </Text>
                  <Button title="Connect" onPress={() => handleConnectWallet(wallet)} />
                </View>
              ))}
            </View>
          )}

          {activeWallet && (
            <View style={styles.buttonWrap}>
              <Button title="Sign Message" onPress={handleSignMessage} />
            </View>
          )}
        </>
      )}

      <View style={styles.buttonWrap}>
        <Button title="Logout" onPress={signOut} />
      </View>
    </ScrollView>
  );
};

const styles = StyleSheet.create({
  container: {
    flexGrow: 1,
    padding: 20,
    alignItems: 'center',
    justifyContent: 'center',
  },
  section: {
    width: '100%',
    maxWidth: 480,
    marginBottom: 12,
  },
  label: {
    fontWeight: 'bold',
    marginBottom: 4,
  },
  subText: {
    fontSize: 12,
    color: '#666',
  },
  buttonWrap: {
    width: '100%',
    maxWidth: 480,
    marginTop: 8,
  },
  walletItem: {
    padding: 10,
    backgroundColor: '#f5f5f5',
    borderRadius: 8,
    marginBottom: 8,
  },
});
```

:::

## Create your main app logic

Wire everything together:

:::code-group

```tsx [With expo-router]
// app/index.tsx
import LoginScreen from '../components/LoginScreen';
import { UserScreen } from '../components/UserScreen';
import { useUser } from '@openfort/react-native';

export default function Index() {
  const { user } = useUser();
  return !user ? <LoginScreen /> : <UserScreen />;
}
```

```tsx [Without expo-router]
// MainApp.tsx
import LoginScreen from './components/LoginScreen';
import { UserScreen } from './components/UserScreen';
import { useUser } from '@openfort/react-native';

export default function MainApp() {
  const { user } = useUser();
  return !user ? <LoginScreen /> : <UserScreen />;
}
```

:::

## Build and run your app

Build and run on a physical device (passkeys require hardware security):

```bash
# Rebuild native code
npx expo prebuild --clean
```

:::code-group

```bash [iOS]
npm run ios
```

```bash [Android]
npm run android
```

:::

## Next steps

* Add gas sponsorship policies for gasless transactions
* Explore advanced wallet features in the [React Native documentation](/docs/products/embedded-wallet/react-native)
* View our React Native sample app with passkey support:

<HoverCardLink
  description="View our React Native sample with passkey recovery. A complete implementation of the Openfort SDK for React Native."
  href="https://github.com/openfort-xyz/react-native-auth-sample"
  title="View sample"
  subtitle="React Native Sample"
  img={{
  src: "/img/icons/react-icon.svg",
  alt: "View sample",
}}
  color="#61DAFB"
/>

::::::
