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 automatically generates a random encryption key, stores it in the device’s secure enclave, and gates it behind biometric authentication. The user just taps “Create Wallet” and scans their face or fingerprint — no PIN to remember.
This is a native mobile implementation. For browser (Next.js / React), see Use Passkeys (React) which uses WebAuthn instead.

Prerequisites

  • Expo SDK 49 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

Pass usePasskey: true and an externalUserId — the SDK handles everything else:
import { useCreateWallet } from '@chipi-stack/chipi-expo';
import * as SecureStore from 'expo-secure-store';
import { useAuth } from '@clerk/clerk-expo';

export function CreateWalletScreen() {
  const { getToken, userId } = useAuth();
  const { createWalletAsync, isLoading, error } = useCreateWallet();

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

    // Face ID / Touch ID prompt is shown automatically
    const result = await createWalletAsync({
      params: {
        usePasskey: true,
        externalUserId: userId,
      },
      bearerToken: token,
    });

    // Persist the wallet (no PIN to store — biometrics handle auth)
    await SecureStore.setItemAsync('wallet', JSON.stringify(result.wallet));
  };

  return (/* your UI */);
}

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 } 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: '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: "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,
    });

    // Replace the stored wallet with the re-encrypted version
    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 } 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, TouchableOpacity, Alert, StyleSheet } from 'react-native';

export function CreateWalletWithPasskey() {
  const { getToken, userId } = useAuth();
  const { createWalletAsync, isLoading, error } = useCreateWallet();
  const [wallet, setWallet] = useState(null);

  const handleCreate = async () => {
    try {
      const token = await getToken();
      if (!token || !userId) {
        Alert.alert('Error', 'No auth token or user ID found.');
        return;
      }

      // Face ID / Touch ID prompt shown automatically
      const result = await createWalletAsync({
        params: {
          usePasskey: true,
          externalUserId: userId,
        },
        bearerToken: token,
      });

      await SecureStore.setItemAsync('wallet', JSON.stringify(result.wallet));
      setWallet(result.wallet);
      Alert.alert('Success', 'Wallet created and secured with biometrics!');
    } 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 IDno PIN needed</Text>

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

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

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20 },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 8 },
  subtitle: { color: '#687076', marginBottom: 24 },
  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)