Skip to main content

Overview

On mobile, passkeys work differently from browsers. Instead of WebAuthn, the Chipi Expo SDK uses:
  • expo-local-authentication — Face ID, Touch ID, and fingerprint gate
  • expo-secure-store — iOS Keychain / Android Keystore for protected key storage
When you set usePasskey: true, the SDK generates a random encryption key, stores it in the device’s secure enclave, and gates it behind biometric authentication.
Always pair a passkey with a PIN backup. The Chipi Passkeys guide (dual-key architecture) requires every passkey wallet to have a mandatory PIN backup so that biometric deletion, device reset, or OS-level passkey loss does not permanently lock the wallet. Pass the user’s PIN as encryptKey alongside usePasskey: true — the SDK stores two encrypted copies of the private key: one unlocked by the biometric key, one unlocked by the PIN. Passkey-only mode (no encryptKey) is supported but labelled not recommended in the SDK source and should not be used in production.
This is a native mobile implementation. For browser (Next.js / React), see Use Passkeys (React) which uses WebAuthn instead.

Prerequisites

  • Expo SDK 55 or later
  • A physical iOS or Android device with biometrics enrolled (Face ID, Touch ID, or fingerprint)
  • A development build — biometrics are not supported in Expo Go
The iOS Simulator does not support biometric authentication. You must test on a real device or configure the simulator to use device passcode fallback.

Installation

npx expo install expo-local-authentication expo-secure-store

Configuration

Add the expo-local-authentication plugin to your app.json to request Face ID permission on iOS:
{
  "expo": {
    "plugins": [
      [
        "expo-local-authentication",
        {
          "faceIDPermission": "Allow $(PRODUCT_NAME) to use Face ID to secure your wallet."
        }
      ]
    ]
  }
}
If you skip faceIDPermission, Apple will reject your app during review and Face ID will fall back to device passcode on iOS without an explanation to the user.
After updating app.json, rebuild your development client:
npx expo run:ios
# or
npx expo run:android

Usage

Create a wallet with biometric passkey + PIN backup

Pass usePasskey: true, an externalUserId, and the user’s PIN as encryptKey. The SDK uses the biometric-derived key as the primary encryption key and stores a second copy of the private key encrypted with the PIN as a recovery fallback.
import { useCreateWallet, Chain } from '@chipi-stack/chipi-expo';
import * as SecureStore from 'expo-secure-store';
import { useAuth } from '@clerk/clerk-expo';
import { useState } from 'react';

export function CreateWalletScreen() {
  const { getToken, userId } = useAuth();
  const { createWalletAsync, isLoading, error } = useCreateWallet();
  const [pin, setPin] = useState('');

  const handleCreateWallet = async () => {
    const token = await getToken();
    if (!token || !userId || pin.length < 4) return;

    // Face ID / Touch ID prompt is shown automatically.
    // `encryptKey: pin` wires up the PIN backup — see
    // chipi-expo/src/hooks/useCreateWallet.ts for the dual-key swap logic.
    const wallet = await createWalletAsync({
      params: {
        usePasskey: true,
        externalUserId: userId,
        encryptKey: pin,
        chain: Chain.STARKNET,
      },
      bearerToken: token,
    });

    // `createWalletAsync` returns a flat `GetWalletResponse` (no nested
    // `wallet` field — see types/src/wallet.ts: `CreateWalletResponse = GetWalletResponse`).
    await SecureStore.setItemAsync('wallet', JSON.stringify(wallet));
  };

  return (/* your UI — collect the PIN, then call handleCreateWallet */);
}

Sign a transaction (retrieve the key with biometrics)

When you need the encryption key later (e.g. to sign a transfer), retrieve it from secure storage — this automatically triggers the Face ID / Touch ID prompt:
import { useTransfer, getNativeWalletEncryptKey, ChainToken } from '@chipi-stack/chipi-expo';
import * as SecureStore from 'expo-secure-store';
import { useAuth } from '@clerk/clerk-expo';

export function TransferScreen() {
  const { getToken, userId } = useAuth();
  const { transferAsync, isLoading } = useTransfer();

  const handleTransfer = async () => {
    const token = await getToken();
    if (!token || !userId) return;

    // Triggers Face ID / Touch ID automatically
    const encryptKey = await getNativeWalletEncryptKey(userId);
    if (!encryptKey) throw new Error('No wallet key found. Please re-create your wallet.');

    const storedWallet = await SecureStore.getItemAsync('wallet');
    if (!storedWallet) throw new Error('No wallet found.');

    await transferAsync({
      params: {
        encryptKey,
        wallet: JSON.parse(storedWallet),
        token: ChainToken.USDC,
        recipient: '0x...',
        amount: 1.0,
      },
      bearerToken: token,
    });
  };

  return (/* your UI */);
}

Use usePasskey: true directly in transaction hooks

useTransfer, useApprove, and useCallAnyContract now use the Expo native passkey adapter automatically when wrapped with @chipi-stack/chipi-expo’s ChipiProvider. When you pass usePasskey: true to those hooks, also pass externalUserId (usually your auth provider user id), so the SDK can fetch the correct key from secure storage:
await transferAsync({
  params: {
    wallet,
    token: ChainToken.USDC,
    recipient: "0x...",
    amount: 1,
    usePasskey: true,
    externalUserId: userId,
  },
  bearerToken: token,
});

Migrate an existing PIN wallet to biometrics

If your users already have a PIN-based wallet, migrate them with one call:
import { useMigrateWalletToPasskey } from '@chipi-stack/chipi-expo';
import * as SecureStore from 'expo-secure-store';
import { useAuth } from '@clerk/clerk-expo';

export function MigrateScreen({ currentPin }: { currentPin: string }) {
  const { getToken, userId } = useAuth();
  const { migrateWalletToPasskeyAsync, isLoading, error } = useMigrateWalletToPasskey();

  const handleMigrate = async () => {
    const token = await getToken();
    const storedWallet = await SecureStore.getItemAsync('wallet');
    if (!token || !userId || !storedWallet) return;

    // Face ID / Touch ID prompt is shown during migration
    const result = await migrateWalletToPasskeyAsync({
      wallet: JSON.parse(storedWallet),
      oldEncryptKey: currentPin,
      externalUserId: userId,
      bearerToken: token,
    });

    // `migrateWalletToPasskeyAsync` returns MigrateWalletToPasskeyResult,
    // a wrapped shape: { success: boolean, wallet: WalletData, credentialId: string }.
    // See chipi-react/src/hooks/useMigrateWalletToPasskey.ts for the type.
    // Replace the stored wallet with the re-encrypted inner `wallet` field:
    await SecureStore.setItemAsync('wallet', JSON.stringify(result.wallet));
    // currentPin no longer works — biometrics are now required
  };

  return (/* your UI */);
}
After migration, the old PIN (oldEncryptKey) will no longer decrypt the wallet. Store the updated wallet object returned by migrateWalletToPasskeyAsync immediately.

Full Example

import { useCreateWallet, Chain } from '@chipi-stack/chipi-expo';
import type { GetWalletResponse } from '@chipi-stack/chipi-expo';
import * as SecureStore from 'expo-secure-store';
import { useAuth } from '@clerk/clerk-expo';
import { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, Alert, StyleSheet } from 'react-native';

export function CreateWalletWithPasskey() {
  const { getToken, userId } = useAuth();
  const { createWalletAsync, isLoading, error } = useCreateWallet();
  const [pin, setPin] = useState('');
  const [wallet, setWallet] = useState<GetWalletResponse | null>(null);

  const handleCreate = async () => {
    try {
      const token = await getToken();
      if (!token || !userId) {
        Alert.alert('Error', 'No auth token or user ID found.');
        return;
      }
      if (pin.length < 4) {
        Alert.alert('Error', 'Please set a PIN (at least 4 digits) as your backup.');
        return;
      }

      // Face ID / Touch ID prompt shown automatically.
      // `encryptKey: pin` wires the dual-key flow (biometric primary + PIN backup).
      const created = await createWalletAsync({
        params: {
          usePasskey: true,
          externalUserId: userId,
          encryptKey: pin,
          chain: Chain.STARKNET,
        },
        bearerToken: token,
      });

      // `createWalletAsync` returns a flat GetWalletResponse — the wallet
      // fields (publicKey, normalizedPublicKey, etc.) live at the top level
      // of `created`, not nested under `created.wallet`.
      await SecureStore.setItemAsync('wallet', JSON.stringify(created));
      setWallet(created);
      Alert.alert('Success', 'Wallet created — biometrics primary, PIN backup.');
    } catch (err) {
      Alert.alert('Error', err instanceof Error ? err.message : String(err));
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Create Wallet</Text>
      <Text style={styles.subtitle}>Secured with Face ID / Touch ID + PIN backup</Text>

      <TextInput
        style={styles.pinInput}
        placeholder="Set a PIN (min 4 digits — used as backup if biometrics fail)"
        value={pin}
        onChangeText={setPin}
        keyboardType="number-pad"
        secureTextEntry
        maxLength={8}
      />

      <TouchableOpacity
        style={[styles.button, isLoading && styles.buttonDisabled]}
        onPress={handleCreate}
        disabled={isLoading || pin.length < 4}
      >
        <Text style={styles.buttonText}>
          {isLoading ? 'Creating...' : 'Create Wallet with Biometrics + PIN'}
        </Text>
      </TouchableOpacity>

      {error && <Text style={styles.error}>{error.message}</Text>}
      {wallet && (
        <Text style={styles.address} numberOfLines={2}>
          {wallet.normalizedPublicKey ?? wallet.publicKey}
        </Text>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20 },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 8 },
  subtitle: { color: '#687076', marginBottom: 24 },
  pinInput: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 12, fontSize: 16, marginBottom: 16 },
  button: { backgroundColor: '#007AFF', borderRadius: 8, padding: 16, alignItems: 'center' },
  buttonDisabled: { opacity: 0.5 },
  buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
  error: { color: '#ff3b30', marginTop: 12 },
  address: { fontFamily: 'monospace', fontSize: 12, marginTop: 16, color: '#333' },
});

How it Works

StepWhat happens
Wallet creationSDK generates a random 32-byte key, prompts Face ID / Touch ID, stores key in iOS Keychain / Android Keystore with biometric protection
Wallet usegetNativeWalletEncryptKey(userId) retrieves the key — this automatically triggers the biometric prompt
MigrationOld PIN decrypts the private key, new biometric key re-encrypts it

Security Notes

  • The encryption key is stored in the device’s secure enclave (iOS Keychain / Android Keystore)
  • requireAuthentication: true means the key cannot be read without biometric approval
  • The key never leaves the device
  • If the user uninstalls the app or re-installs it, the key is lost — ensure your users understand wallet recovery options

Utilities Reference

ExportDescription
isNativeBiometricSupported()Check if the device has enrolled biometrics
createNativeWalletPasskey(userId, userName)Manually create a passkey (called by useCreateWallet internally)
getNativeWalletEncryptKey(userId)Retrieve the stored key (triggers biometric prompt)
hasNativeWalletPasskey()Check if a passkey was already created on this device
removeNativeWalletPasskey(userId)Delete the stored key (destructive — use with caution)
getNativeWalletCredential()Get credential metadata (credentialId, userId, createdAt)