Documentation Index Fetch the complete documentation index at: https://docs.chipipay.com/llms.txt
Use this file to discover all available pages before exploring further.
What Are Passkeys?
Passkeys use biometric authentication (Face ID, Touch ID, Windows Hello) to protect wallets. Instead of a PIN, a secure encryption key is derived from the biometric prompt. No passwords to remember, no PINs to leak.
Dual-key architecture: Every passkey wallet also has a mandatory PIN backup. If biometrics fail (browser change, device reset), the PIN recovers the wallet. Two independent keys, same private key.
Dual-key flow:
1. Biometric prompt → passkey-derived key → encrypts private key (primary)
2. User enters PIN → PIN encrypts same private key (backup)
3. Both stored in backend. Either can decrypt.
Platform Support
Platform Technology Package Web (Next.js, React) WebAuthn PRF @chipi-stack/chipi-passkeyMobile (Expo) Face ID / Touch ID + SecureStore @chipi-stack/chipi-expo
Installation
Web (Next.js / React)
Mobile (Expo)
npm install @chipi-stack/chipi-passkey@latest
Create Wallet with Passkey + PIN
The recommended flow — passkey is primary, PIN is backup:
"use client" ;
import { useState } from "react" ;
import { useAuth } from "@clerk/nextjs" ;
import { useCreateWallet , Chain } from "@chipi-stack/nextjs" ;
export function CreateWallet ({ userId } : { userId : string }) {
const { getToken } = useAuth ();
const { createWalletAsync , isLoading , error } = useCreateWallet ();
const [ pin , setPin ] = useState ( "" );
const handleCreate = async () => {
const bearerToken = await getToken ();
if ( ! bearerToken ) return ;
// usePasskey: true triggers biometric prompt automatically.
// encryptKey (PIN) becomes the backup — stored as encryptedPrivateKeyBackup.
const wallet = await createWalletAsync ({
params: {
encryptKey: pin ,
externalUserId: userId ,
chain: Chain . STARKNET ,
usePasskey: true ,
},
bearerToken ,
});
// wallet.authMethod === "passkey+pin"
// wallet.encryptedPrivateKey → encrypted with passkey key
// wallet.encryptedPrivateKeyBackup → encrypted with PIN
localStorage . setItem ( "wallet" , JSON . stringify ( wallet ));
};
return (
< div >
< input
type = "password"
placeholder = "Backup PIN (required)"
value = { pin }
onChange = {(e) => setPin (e.target.value)}
/>
< button onClick = { handleCreate } disabled = {isLoading || ! pin } >
{ isLoading ? "Creating..." : "Create Wallet with Passkey" }
</ button >
{ error && < p > Error : { error . message }</ p >}
</ div >
);
}
What happens internally
usePasskey: true → calls createWalletPasskey(userId, userId) → biometric prompt
Returns encryptKey (passkey-derived) + credentialId + prfSupported
Since encryptKey (PIN) was also provided → dual-key mode:
Private key encrypted with passkey key → encryptedPrivateKey
Private key encrypted with PIN → encryptedPrivateKeyBackup
Both sent to backend with authMethod: "passkey+pin"
Source: chipi-react/src/hooks/useCreateWallet.ts lines 62-100
Transfer with Passkey (Automatic PIN Fallback)
"use client" ;
import { useState } from "react" ;
import { useAuth } from "@clerk/nextjs" ;
import { useTransfer , ChainToken } from "@chipi-stack/nextjs" ;
export function Transfer ({ wallet , userId } : { wallet : any ; userId : string }) {
const { getToken } = useAuth ();
const [ recipient , setRecipient ] = useState ( "" );
const [ amount , setAmount ] = useState ( "" );
// onPinRequired: called when passkey fails — show a PIN dialog
const { transferAsync , isLoading , error } = useTransfer ({
onPinRequired : async () => {
return prompt ( "Passkey failed. Enter your backup PIN:" );
},
});
const handleTransfer = async () => {
const bearerToken = await getToken ();
if ( ! bearerToken ) return ;
const txHash = await transferAsync ({
params: {
wallet ,
token: ChainToken . USDC ,
recipient ,
amount: Number ( amount ),
usePasskey: true ,
externalUserId: userId ,
},
bearerToken ,
});
alert ( `Transfer complete: ${ txHash } ` );
};
return (
< div >
< input placeholder = "Recipient" value = { recipient } onChange = {(e) => setRecipient (e.target.value)} />
< input placeholder = "Amount" type = "number" value = { amount } onChange = {(e) => setAmount (e.target.value)} />
< button onClick = { handleTransfer } disabled = { isLoading } >
{ isLoading ? "Sending..." : "Send with Passkey" }
</ button >
{ error && < p > Error : { error . message }</ p >}
</ div >
);
}
Transfer flow
usePasskey: true → tries passkey authentication (biometric prompt)
If passkey succeeds → decrypts encryptedPrivateKey with passkey key → signs tx
If passkey fails (PRF unavailable, user cancelled, localStorage cleared):
Checks if wallet.encryptedPrivateKeyBackup exists
If yes → calls onPinRequired() → user enters PIN → decrypts backup key → signs tx
If no backup or no onPinRequired callback → throws descriptive error
Source: chipi-react/src/hooks/useTransfer.ts lines 65-110
UseTransferConfig
interface UseTransferConfig {
onPinRequired ?: () => Promise < string | null >;
}
Option Type Description onPinRequired() => Promise<string | null>Called when passkey fails and backup exists. Return PIN string or null to cancel.
PIN-Only Mode (Backward Compatible)
Omit usePasskey: true for the same PIN-only flow as before:
const wallet = await createWalletAsync ({
params: {
encryptKey: pin ,
externalUserId: userId ,
chain: Chain . STARKNET ,
// No usePasskey — PIN-only mode
},
bearerToken ,
});
// wallet.authMethod === "pin"
Migrate from PIN to Passkey
For existing PIN-only wallets:
import { useMigrateWalletToPasskey } from "@chipi-stack/nextjs" ;
const { migrateWalletToPasskeyAsync } = useMigrateWalletToPasskey ();
await migrateWalletToPasskeyAsync ({
wallet ,
oldEncryptKey: currentPin ,
externalUserId: userId ,
bearerToken ,
});
// Wallet re-encrypted with passkey key. Backend updated.
Check Passkey Status
import { usePasskeyStatus } from "@chipi-stack/chipi-passkey/hooks" ;
import { verifyWalletPasskeyDetailed } from "@chipi-stack/chipi-passkey" ;
// Quick check
const { status } = usePasskeyStatus ();
// status.isSupported, status.hasPasskey, status.isValid
// Detailed check (distinguishes PRF unavailable from missing passkey)
const result = await verifyWalletPasskeyDetailed ();
// result: { valid: true } or { valid: false, reason: "prf_unavailable", message: "..." }
Expo (Mobile) Passkeys
On mobile, the same usePasskey: true flag works automatically — the Expo ChipiProvider injects a native biometric adapter.
import { ChipiProvider } from "@chipi-stack/chipi-expo" ;
import { useCreateWallet } from "@chipi-stack/chipi-expo" ;
// Same API as web — usePasskey: true uses Face ID / Touch ID
const wallet = await createWalletAsync ({
params: {
encryptKey: pin ,
externalUserId: userId ,
chain: Chain . STARKNET ,
usePasskey: true ,
},
bearerToken ,
});
If biometrics fail (user unenrolled, cancelled), the onPinRequired callback in useTransfer handles the fallback — same as web.
Hooks Reference
Hook Purpose usePasskeySetupCreate a new passkey manually (advanced — prefer usePasskey: true on useCreateWallet) usePasskeyAuthAuthenticate with existing passkey manually (advanced — prefer usePasskey: true on useTransfer) usePasskeyStatusCheck WebAuthn/biometric support and stored passkey validity verifyWalletPasskeyDetailedDetailed check with reason: "prf_unavailable", "no_passkey", "error" useMigrateWalletToPasskeyMigrate existing PIN wallet to passkey
Security
Dual-key wrapping : Same private key encrypted by two independent keys. Either recovers.
No silent fallback : If passkey was created with PRF, PBKDF2 fallback is blocked (prevents wrong-key decryption).
Backend credential recovery : credentialId stored server-side. If localStorage cleared, SDK recovers from backend.
Hardware-backed : Keys in Secure Enclave (iOS), Android Keystore, or WebAuthn authenticator.
Related