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

# Passkeys

> Passkey-only wallets for self-custodial apps, with an optional PIN backup

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

`useCreateWallet` supports three modes:

| Mode | Params | `authMethod` | Recovery |
| - | - | - | - |
| **Passkey only (recommended)** | `usePasskey: true`, no `encryptKey` | `"passkey"` | The passkey itself, synced by the platform (iCloud Keychain, Google Password Manager) |
| Passkey + PIN backup | `usePasskey: true` and `encryptKey: pin` | `"passkey+pin"` | The synced passkey, or the PIN |
| PIN only (legacy) | `encryptKey: pin` | `"pin"` | The PIN |

On the web the passkey's wrap key is the WebAuthn **PRF** output, derived on the device: Chipi never sees it and cannot recompute it. If the device or browser can't produce PRF output, wallet creation stops with `PRF_UNSUPPORTED` instead of falling back to a server-derivable key (see [When passkeys fail](/sdk/passkeys/when-passkeys-fail)).

A PIN backup is optional. It is a weaker secret: the backup ciphertext is stored by the backend, and a short PIN can be brute-forced offline against it. Add one only if your users need a recovery path that doesn't depend on passkey sync.

> [!NOTE]
>
> The passkey is bound to your site's hostname (`rpId` is `window.location.hostname`), so it unlocks the wallet only on that hostname. `useCreateWallet` names the passkey after the `externalUserId` you pass (user name `externalUserId`, display name `"<externalUserId> - Wallet Key"`), and that is what the OS passkey picker shows. Pass a value you are comfortable showing the user.

## Platform Support

| Platform | Technology | Package |
| - | - | - |
| **Web (Next.js, React)** | WebAuthn PRF | `@chipi-stack/chipi-passkey` |
| **Mobile (Expo)** | Face ID / Touch ID + SecureStore | `@chipi-stack/chipi-expo` |

## Installation

```bash Web (Next.js / React) theme={null}
npm install @chipi-stack/chipi-passkey@latest
```

```bash Mobile (Expo) theme={null}
npx expo install expo-local-authentication expo-secure-store
```

## Create a passkey-only wallet (recommended)

```typescript theme={null}
"use client";

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 handleCreate = async () => {
    const bearerToken = await getToken();
    if (!bearerToken) return;

    try {
      // usePasskey: true creates the passkey (one biometric prompt) and
      // encrypts the private key with its PRF output. No PIN.
      const wallet = await createWalletAsync({
        params: {
          externalUserId: userId,
          chain: Chain.STARKNET,
          usePasskey: true,
        },
        bearerToken,
      });
      // wallet.authMethod === "passkey"
      // wallet.credentialId and wallet.prfSupported are stored on the wallet row
    } catch (err) {
      if ((err as { code?: string }).code === "PRF_UNSUPPORTED") {
        // This device/browser can't create a self-custodial wallet. Ask the user to switch.
      }
      throw err;
    }
  };

  return (
    <div>
      <button onClick={handleCreate} disabled={isLoading}>
        {isLoading ? "Creating..." : "Create Wallet with Passkey"}
      </button>
      {error && <p>Error: {error.message}</p>}
    </div>
  );
}
```

## Create a wallet with passkey + PIN backup

Passkey is primary, PIN is backup:

```typescript theme={null}
"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"
        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(externalUserId, externalUserId)` → biometric prompt. It throws `PrfUnsupportedError` (`code: "PRF_UNSUPPORTED"`) when the authenticator has no PRF; the hook rethrows it as `Error("Passkey creation failed: …")` with the same `code`.
2. Returns `encryptKey` (passkey-derived) + `credentialId` + `prfSupported`
3. Without `encryptKey` → passkey-only: private key encrypted with the passkey key → `encryptedPrivateKey`, `authMethod: "passkey"`
4. With `encryptKey` (PIN) → dual-key mode:
   * Private key encrypted with passkey key → `encryptedPrivateKey`
   * Private key encrypted with PIN → `encryptedPrivateKeyBackup`
   * `authMethod: "passkey+pin"`
5. `credentialId` and `prfSupported` are sent with the wallet, so `getWallet` returns them on any device

**Source:** `chipi-react/src/hooks/useCreateWallet.ts`

## Unlock the wallet on another device

The passkey syncs to the user's other devices through their platform account. What doesn't sync is the browser's `localStorage`, where `createWalletPasskey` keeps the credential metadata. `getWalletEncryptKey()` with no argument reads `localStorage`, so on a new device it throws `"No wallet passkey found"`.

Pass the values stored on the wallet row instead:

```typescript theme={null}
import { getWalletEncryptKey } from "@chipi-stack/nextjs";

// wallet comes from useChipiWallet / useGetWallet (GetWalletResponse)
const encryptKey = await getWalletEncryptKey(wallet.credentialId ?? undefined, {
  prfSupported: wallet.prfSupported ?? undefined,
});
// null if the user cancelled the prompt
```

The signature is `getWalletEncryptKey(credentialId?: string, options?: { prfSupported?: boolean })`. `prfSupported` tells it the wallet was created with PRF, so it never falls back to a different key derivation when a browser returns no PRF output; it throws `"Passkey PRF unavailable…"` instead.

The transaction hooks (`useTransfer`, `useApprove`, `useCallAnyContract`) accept the same `credentialId` param next to `usePasskey: true`.

## Transfer with Passkey (Automatic PIN Fallback)

```typescript theme={null}
"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,
        // From the wallet row: lets a device that didn't create the passkey find it
        credentialId: wallet.credentialId ?? undefined,
      },
      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

```typescript theme={null}
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 (Legacy)

Omit `usePasskey: true` for the same PIN-only flow as before:

```typescript theme={null}
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:

```typescript theme={null}
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

```typescript theme={null}
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.

> [!WARNING]
>
> On Expo the "passkey" is not a WebAuthn passkey: it is a random key stored in this device's Keychain / Keystore (`expo-secure-store`) behind biometrics. The SDK keeps it only on this device, so a biometric-only Expo wallet is lost with the device. Keep the PIN backup on Expo.

```typescript theme={null}
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 |
| - | - |
| `usePasskeySetup` | Create a new passkey manually (advanced — prefer `usePasskey: true` on `useCreateWallet`) |
| `usePasskeyAuth` | Authenticate with existing passkey manually (advanced — prefer `usePasskey: true` on `useTransfer`) |
| `usePasskeyStatus` | Check WebAuthn/biometric support and stored passkey validity |
| `verifyWalletPasskeyDetailed` | Detailed check with reason: `"prf_unavailable"`, `"no_passkey"`, `"error"` |
| `useMigrateWalletToPasskey` | Migrate existing PIN wallet to passkey |

## Security

* **Device-derived wrap key**: On the web the passkey's PRF output encrypts the private key. Without PRF, creation fails with `PRF_UNSUPPORTED` unless you pass `requirePrf: false` to `createWalletPasskey`, which makes the wallet custodial.
* **Optional dual-key wrapping**: With a PIN, the same private key is also encrypted with the PIN. Either recovers it, and the PIN is the weaker of the two.
* **No silent fallback**: If passkey was created with PRF, PBKDF2 fallback is blocked (prevents wrong-key decryption).
* **Credential metadata on the wallet row**: `credentialId` and `prfSupported` are stored server-side and returned by `getWallet`. If `localStorage` is cleared or the user is on a new device, pass them to `getWalletEncryptKey` (or `credentialId` to the transaction hooks).
* **Hardware-backed**: Keys in Secure Enclave (iOS), Android Keystore, or WebAuthn authenticator.

## Related

* [Session Keys Guide](/sdk/guides/session-keys) — Combine passkeys with session keys
* [useCreateWallet](/sdk/nextjs/hooks/use-create-wallet) — Wallet creation hook reference
* [useTransfer](/sdk/nextjs/hooks/use-transfer) — Transfer hook reference
* [useMigrateWalletToPasskey](/sdk/nextjs/hooks/use-migrate-wallet-to-passkey) — Migration hook reference


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.