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

# When Passkeys Fail

> Real failure modes — lost device, browser switch, broken biometrics — and what your app should show the user. Plus when an optional PIN backup helps.

Passkeys cover the happy path beautifully. The failure modes — the user buys a new laptop, switches from Chrome to Firefox, drops their phone — need explicit handling. This page is the playbook.

## The four failure modes

| What happened | What the SDK throws | What the user sees | What you do |
| - | - | - | - |
| User cancelled the prompt | `signShhhMessage` rejects with `NotAllowedError` translated to `Error("Passkey authentication was cancelled")` | "Authentication cancelled" | Show the prompt button again. No data loss. |
| New device, no passkey synced | `hasShhhPasskey()` returns `false` | Sign-up page | If the user already has a wallet, route them to recovery (see below). |
| PRF unavailable after a browser switch (PRF-encrypted wallets) | `getWalletEncryptKey` throws `"Passkey PRF unavailable…"` | "Use PIN to sign in" | Show PIN field. The PRF-encrypted wallet can be re-encrypted later via [useUpdateWalletEncryption](/sdk/react/hooks/use-update-wallet-encryption). A passkey-only wallet has no PIN: ask the user to use a browser that returns PRF output for this passkey. |
| Lost device, no other passkey | nothing throws — user is locked out | "Recover your wallet" UI | SHHH: use the guardian recovery flow. PRF-encrypted wallets (CHIPI v29, or `useCreateWallet({ usePasskey: true })`): the PIN backup, if one was created. |
| Non-PRF authenticator **at creation** | `createWalletPasskey` throws `PrfUnsupportedError` (`.code === "PRF_UNSUPPORTED"`) | "This device can't create a secure wallet" | Block creation and ask the user for a PRF-capable device/browser. Do **not** retry on the same authenticator: without PRF the key would be server-derivable (custodial). Opt into the fallback only via `createWalletPasskey(userId, userName, { requirePrf: false })`. |
| Same, through `useCreateWallet({ usePasskey: true })` or `useMigrateWalletToPasskey` | The hook rethrows as `Error("Passkey creation failed: …")` / `Error("Migration failed: …")` with `.code === "PRF_UNSUPPORTED"` (14.13.1+) and the original error as `.cause` | "This device can't create a secure wallet" | Branch on `error.code`, not on the message. Before 14.13.1 the hooks dropped the code. |

## SHHH wallets — true recovery

A SHHH wallet's owner set lives on chain. Recovery is a real on-chain primitive, not a workaround.

**Two paths:**

1. **The user still has access** (e.g. moved from old phone to new) — `propose_add_owner` adds the new passkey as a second owner with a 48-hour timelock, then `execute_add_owner` activates it. See the recovery story in [`/services/gasless/recovery`](/services/gasless/recovery).
2. **The user lost all access** — a pre-registered guardian (a wallet the user already has such as Bramble, a second device, a recovery email's WebAuthn credential, a "Guardian-as-a-Service" partner) signs `initiate_recovery`. After a 7-day timelock anyone can submit `finalize_recovery`: it is permissionless. Recovery is additive: it adds the fresh passkey as a new owner and removes nobody, so remove the lost owners afterwards with `propose_remove_owner` (24h).

The React side ships [`<Recover />`](/services/gasless/recovery) which drives both flows. Path 1 is `mode="add-device"`. A user who lost every key can't sign a proposal, so path 2 is `mode="guardian-recovery"`:

```tsx theme={null}
import { Recover } from "@chipi-stack/chipi-react";
import { createShhhPasskey } from "@chipi-stack/chipi-passkey";
import { webauthnPubkeyFelts, type NewOwnerInput } from "@chipi-stack/backend";

// The guardian signs initiate_recovery; returns { opId, validAfter } (see the recovery page).
declare function startRecoveryWithGuardian(): Promise<{ opId: bigint; validAfter: number }>;
// Anyone submits finalize_recovery after 7 days.
declare function finalizeRecovery(): Promise<void>;

// 1. On the new device, the user registers a fresh passkey. This owner works only on a
//    wallet that already verifies WEBAUTHN_P256. A useCreateWallet({ usePasskey: true })
//    wallet verifies STARK: recover it onto a new STARK key instead (see below).
export async function newPasskeyOwner(userId: string, userName: string): Promise<NewOwnerInput> {
  const { publicKey } = await createShhhPasskey(userId, userName);
  return {
    kind: "WEBAUTHN_P256",
    pubkeyBytes: webauthnPubkeyFelts(publicKey),
    role: "OWNER",
    weight: 1,
    label: "new-device",
  };
}

// 2. A guardian starts the recovery; after 7 days anyone finalizes it.
export function RecoverWallet(props: { walletAddress: string; newOwner: NewOwnerInput }) {
  return (
    <Recover
      mode="guardian-recovery"
      walletAddress={props.walletAddress}
      newOwner={props.newOwner}
      onPropose={startRecoveryWithGuardian}
      onExecute={finalizeRecovery}
    />
  );
}
```

The new owner's kind must already be registered on the wallet: `finalize_recovery` checks the key through that kind's verifier class, and adding a verifier class needs an owner who can still sign. A `WEBAUTHN_P256` owner works on a wallet whose first owner is a `WEBAUTHN_P256` passkey, or one that registered that kind earlier. A wallet from `useCreateWallet({ usePasskey: true })` verifies `STARK` (its passkey only encrypts a STARK key): recover it onto a new STARK key, or register `WEBAUTHN_P256` while the user can still sign. See [New signer kinds](/services/gasless/recovery#new-signer-kinds-need-a-verifier-class).

## PRF-encrypted wallets — optional dual-key fallback

A PRF-encrypted wallet's passkey only encrypts a STARK key; if the passkey is gone, the encryption key is gone with it. This covers CHIPI v29 wallets and any wallet created with `useCreateWallet({ usePasskey: true })` (an SHHH wallet with a STARK signer by default, the kind that supports session keys).

The first line of defense is the passkey itself: platform passkeys sync across the user's devices (iCloud Keychain, Google Password Manager), and on a new device you unlock with `getWalletEncryptKey(wallet.credentialId ?? undefined, { prfSupported: wallet.prfSupported ?? undefined })` (see [Passkeys](/sdk/guides/passkeys#unlock-the-wallet-on-another-device)). A passkey-only wallet is the recommended default for self-custodial web apps.

If you also want a recovery path that doesn't depend on sync, add the **dual-key architecture**: store the same private key TWICE, once encrypted by the passkey, once encrypted by a user-chosen PIN. It is optional, and the PIN is the weaker secret: the backup ciphertext is stored by the backend, and a short PIN can be brute-forced offline against it.

The wallet shape carries two ciphertexts:

```ts theme={null}
type Wallet = {
  publicKey: string;
  encryptedPrivateKey: string;       // passkey-derived key encrypts this
  encryptedPrivateKeyBackup: string; // PIN encrypts this
  authMethod: "passkey+pin";         // marker for the dual-key path
};
```

Create it with `usePasskey: true` and the PIN as `encryptKey`; the hook creates the passkey and makes the PIN the backup:

```ts theme={null}
import { useCreateWallet, Chain } from "@chipi-stack/chipi-react";

const { createWalletAsync } = useCreateWallet();
const pin = await promptForPin();

await createWalletAsync({
  params: {
    externalUserId: userId,
    chain: Chain.STARKNET,
    usePasskey: true,  // creates the passkey; its key encrypts encryptedPrivateKey
    encryptKey: pin,   // becomes the backup: encrypts encryptedPrivateKeyBackup
  },
  bearerToken,
});
// authMethod === "passkey+pin"
```

Don't pass a passkey key you derived yourself as `encryptKey` with `encryptKeyBackup` and `authMethod` to `useCreateWallet`: without `usePasskey: true` the hook sets `authMethod: "pin"` and drops `encryptKeyBackup`.

Day-to-day signing uses the passkey. If the passkey ever fails (`verifyWalletPasskeyDetailed()` returns `reason: "prf_unavailable"` or `"no_passkey"`), prompt for the PIN and use the `encryptedPrivateKeyBackup` ciphertext instead. The SDK handles which ciphertext to pick when you pass the right `encryptKey`.

## PIN-only mode is a fallback, not a default

A PIN-only wallet (no passkey) is supported, but it's the weakest path — see the PIN warning we use across every onboarding doc. Default to passkey; show PIN only when `isWebAuthnSupported()` returns `false` or the user explicitly opts in.

```tsx theme={null}
import { isWebAuthnSupported } from "@chipi-stack/chipi-passkey";

function SignUpFlow() {
  if (isWebAuthnSupported()) {
    return <PasskeySignUp />;
  }
  // Old browser, very old Android, or a corporate-managed device that
  // disables WebAuthn. Fall back to PIN.
  return <PinSignUp />;
}
```

## Browser switching (the common case)

Even fully-supported browsers don't always share PRF state. The user registers on Chrome, then opens your app in Firefox a week later. The Firefox WebAuthn ceremony succeeds — the credential is discoverable — but the PRF output is unavailable in that browser.

For a SHHH wallet with a `WEBAUTHN_P256` signer, this is **not a problem**: the passkey itself is the signer, no encryption key derivation. `signShhhMessage` works on any browser that supports WebAuthn.

For a PRF-encrypted wallet (legacy CHIPI v29, or one created with `useCreateWallet({ usePasskey: true })`), this **is** the problem `verifyWalletPasskeyDetailed()` catches. Surface the PIN fallback UI, decrypt with the PIN, and offer to re-register the passkey on the new browser via the [migration hook](/sdk/react/hooks/use-migrate-wallet-to-passkey).

## Related

* [Overview](/sdk/passkeys/overview)
* [Quickstart](/sdk/passkeys/quickstart)
* [API reference](/sdk/passkeys/api)
* [Recovery on SHHH wallets](/services/gasless/recovery)


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