Skip to main content

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

PlatformTechnologyPackage
Web (Next.js, React)WebAuthn PRF@chipi-stack/chipi-passkey
Mobile (Expo)Face ID / Touch ID + SecureStore@chipi-stack/chipi-expo

Installation

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

  1. usePasskey: true → calls createWalletPasskey(userId, userId) → biometric prompt
  2. Returns encryptKey (passkey-derived) + credentialId + prfSupported
  3. Since encryptKey (PIN) was also provided → dual-key mode:
    • Private key encrypted with passkey key → encryptedPrivateKey
    • Private key encrypted with PIN → encryptedPrivateKeyBackup
  4. 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

  1. usePasskey: true → tries passkey authentication (biometric prompt)
  2. If passkey succeeds → decrypts encryptedPrivateKey with passkey key → signs tx
  3. 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>;
}
OptionTypeDescription
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

HookPurpose
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.