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
Create Wallet
Transfer with Biometrics
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 ID — no 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' },
});
import { useTransfer, getNativeWalletEncryptKey } from '@chipi-stack/chipi-expo';
import * as SecureStore from 'expo-secure-store';
import { useAuth } from '@clerk/clerk-expo';
import { useState } from 'react';
import { View, TextInput, TouchableOpacity, Alert, Text, StyleSheet } from 'react-native';
export function TransferWithPasskey() {
const { getToken, userId } = useAuth();
const { transferAsync, isLoading } = useTransfer();
const [recipient, setRecipient] = useState('');
const [amount, setAmount] = useState('');
const handleTransfer = async () => {
try {
const token = await getToken();
if (!token || !userId) return;
// Triggers Face ID / Touch ID — user authenticates to access the key
const encryptKey = await getNativeWalletEncryptKey(userId);
if (!encryptKey) {
Alert.alert('Error', 'No wallet key found. Create a wallet first.');
return;
}
const storedWallet = await SecureStore.getItemAsync('wallet');
if (!storedWallet) {
Alert.alert('Error', 'No wallet found.');
return;
}
const txHash = await transferAsync({
params: {
encryptKey,
wallet: JSON.parse(storedWallet),
token: 'USDC',
recipient,
amount: Number(amount),
},
bearerToken: token,
});
Alert.alert('Success', `Transfer sent! TX: ${txHash}`);
setRecipient('');
setAmount('');
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : String(err));
}
};
return (
<View style={styles.container}>
<TextInput
style={styles.input}
placeholder="Recipient address (0x...)"
value={recipient}
onChangeText={setRecipient}
/>
<TextInput
style={styles.input}
placeholder="Amount (USDC)"
value={amount}
onChangeText={setAmount}
keyboardType="decimal-pad"
/>
<TouchableOpacity
style={[styles.button, isLoading && styles.disabled]}
onPress={handleTransfer}
disabled={isLoading || !recipient || !amount}
>
<Text style={styles.buttonText}>
{isLoading ? 'Sending...' : 'Send USDC (Biometric Auth)'}
</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 12, padding: 20 },
input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 12, fontSize: 16 },
button: { backgroundColor: '#007AFF', borderRadius: 8, padding: 16, alignItems: 'center' },
disabled: { opacity: 0.5 },
buttonText: { color: '#fff', fontWeight: '600' },
});
How it Works
| Step | What happens |
|---|
| Wallet creation | SDK generates a random 32-byte key, prompts Face ID / Touch ID, stores key in iOS Keychain / Android Keystore with biometric protection |
| Wallet use | getNativeWalletEncryptKey(userId) retrieves the key — this automatically triggers the biometric prompt |
| Migration | Old 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
| Export | Description |
|---|
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) |