The four failure modes
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:- The user still has access (e.g. moved from old phone to new) —
propose_add_owneradds the new passkey as a second owner with a 48-hour timelock, thenexecute_add_owneractivates it. See the recovery story in/services/gasless/recovery. - 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 submitfinalize_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 withpropose_remove_owner(24h).
<Recover /> 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":
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.
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 withuseCreateWallet({ 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). 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:
usePasskey: true and the PIN as encryptKey; the hook creates the passkey and makes the PIN the backup:
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 whenisWebAuthnSupported() returns false or the user explicitly opts in.
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 aWEBAUTHN_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.
