Cavos

Passkeys (2FA)

Set up a passkey after signup so users can log in on a new device — a second factor for device approval, not a signer.

Right after signup, walk the user through creating a passkey. It is a second factor for their wallet: it does not replace the silent device key and it never signs transactions. What it does is let them log in on a new device without having to find one that's already logged in.

Treat it as two-factor authentication in your UI. Show the prompt immediately after the wallet is ready, framed as "Turn on device approvals so you never get locked out" — not as a signing step. That framing is what keeps the UX coherent: the user already authenticated and got a wallet, and this is the safety net on top.

A passkey here is a synced, discoverable WebAuthn credential (iCloud Keychain / Google Password Manager) registered on-chain as an approver. It approves adding a new device's key — it does not authorize individual transactions. Signing stays silent, as described in Signing & gasless.

Why a passkey

Without a passkey, adding a new device requires either an already-registered device (the multi-device approval flow) or the recovery passphrase (recovery). Both can fail the user at the worst moment: the other device isn't nearby, or the code was lost.

A passkey removes that dependency. Because it syncs across the user's devices through the OS, a single WebAuthn prompt from any browser can authorize the new device's key on-chain — no second device hunt, no passphrase. It is the lowest-friction path to "log in on my new phone."

Control when it's asked (React SDK)

If you use the CavosProvider with the built-in auth modal, the kit shows a "Secure your account" step automatically right after a brand-new wallet is created. You decide — per app — whether that step interrupts onboarding, runs as an optional nudge, or is skipped entirely. The secureStep modal option is the single knob for this:

app/layout.tsx
import { CavosProvider } from '@cavos/kit/react';

<CavosProvider
  config={config}
  modal={{
    appName: 'My App',
    // When the passkey / recovery prompt appears after signup:
    secureStep: 'optional', // 'optional' | 'required' | 'off'
  }}
>
  {children}
</CavosProvider>
secureStepBehavior after a new account is created
'optional' (default)Shows the "Secure your account" screen with a Skip for now button. Low friction — the user can defer passkey setup and still finish onboarding.
'required'Shows the screen without Skip. The user must set up a passkey (or save a recovery passphrase) before they can continue. Use this for high-value apps where a second factor is mandatory.
'off'Skips the screen entirely — onboarding ends the moment the wallet is ready. Choose this when you want to drive the 2FA prompt yourself later in the journey (e.g. a "Secure your account" banner on the dashboard), instead of interrupting signup.

Picking the right moment

The right value depends on where in the user journey asking for a passkey feels natural, not disruptive:

  • 'off' — ask later. Friction-free onboarding. Drop the user straight into the app, then surface the passkey prompt from your own UI once they've hit an "aha" moment (first transaction, settings page, or a dedicated security screen). You can still enroll any time with enrollPasskey from useCavos()secureStep: 'off' only removes the automatic post-signup screen.
  • 'optional' — ask now, let them skip. The default. Good when you want to plant the seed early but respect the user's pace; the skip keeps conversion high while the prompt keeps the option visible.
  • 'required' — ask now, no skipping. Best for custody-like UX, treasuries, or any context where losing device access is unacceptable. Pair it with the recovery passphrase fallback (the screen offers both) so the user always has a path forward even without a platform authenticator.

The passkey setup is gasless, idempotent, and device-signed — the same call described in Set it up after signup. secureStep only controls whether and when the built-in modal shows it automatically; the underlying enrollPasskey API is always available to trigger on your own terms.

Set it up after signup

Once connect returns status: "ready", gate on platform support and enroll. enrollPasskey does two things in one call: it creates the credential (a system prompt the user confirms with biometrics / screen lock) and registers its public key on-chain as an approver via addApprover. It is gasless, device-signed, and idempotent.

TypeScript
import { Cavos, PasskeySigner } from "@cavos/kit";

// cavos is ready (status === "ready") right after signup.
if (await PasskeySigner.isSupported()) {
  const passkey = new PasskeySigner();               // rpId defaults to your hostname
  const { publicKey } = await cavos.enrollPasskey(passkey, {
    userId: identity.userId,
    userName: identity.email ?? identity.userId,
  });
  // publicKey { x, y } is now a registered approver of this wallet.
}

Prompt for the passkey as a 2FA / "secure your account" step right after the wallet is created — not during the auth flow and not as part of signing. The user should never associate this credential with approving individual transactions; it only ever appears when a new device is being added.

Only the passkey's public key is registered on-chain. The credential itself is held and synced by the OS authenticator — the kit never stores it.

Approve a new device with a passkey

When the same identity connects on a fresh browser, it lands on the same wallet address but with status: "needs-device-approval" — this device's key isn't an authorized signer yet. If the user enrolled a passkey, they can self-approve from that new browser in one prompt:

TypeScript
const cavos = await Cavos.connect({ /* ...same identity + appSalt... */ });

if (cavos.status === "needs-device-approval") {
  const passkey = new PasskeySigner();
  // One prompt: the synced passkey authorizes adding THIS device's key.
  await cavos.approveThisDeviceWithPasskey({ passkey });
  // cavos is now ready; no second device required.
}

Internally, the kit builds a per-chain "leaf" from the new device key and the on-chain passkey nonce, the passkey signs a challenge over it, and an add_signer_via_passkey call is submitted. The assertion does not carry the public key, so the kit recovers both secp256r1 candidates and matches the enrolled approver via an on-chain view — no backend lookup.

Advanced — multiple independent chains. Each Cavos wallet handle is one chain. If your app manages several chain wallets for the same user, the low-level approveDeviceEverywhere([wallet, ...], passkey) util approves the device on each with a single passkey prompt (one batched challenge, isolated per-chain submission). It returns { chain, transactionHash?, error? }[], and reusing the same passkey pubkey across chains is a per-wallet addApprover(pubkey) (idempotent). This covers Starknet and Solana (the on-chain assertion model on this page). Most integrations target one chain and never need this.

Stellar passkeys work differently. The classic G… account has no on-chain WebAuthn verifier. Instead, the passkey is a WebAuthn PRF credential whose derived secret unlocks the account's control key — enroll it with stellarWallet.enrollPasskey(prfOutput) and approve a new device with stellarWallet.approveThisDeviceWithPasskey(prfOutput). It is not part of the approveDeviceEverywhere batch and does not use addApprover / add_signer_via_passkey. The React CavosProvider wraps this for you via enrollPasskeyDefault() / approveDeviceWithPasskey().

Prerequisites & limits

  • Secure context required. WebAuthn needs HTTPS or http://localhost. A LAN IP fails — the kit throws an actionable error pointing you to localhost, a real HTTPS domain, or a tunnel (cloudflared / ngrok). The silent device key works over an IP; passkeys don't.
  • Check support first. Gate enrollment on await PasskeySigner.isSupported() (it checks for a platform authenticator). On unsupported platforms, fall back to the multi-device and recovery flows.
  • RP ID. Defaults to window.location.hostname. Pass rpId explicitly only if you need a different registrable domain.
  • Starknet mainnet caveat. Passkey approval currently works on Sepolia only. The mainnet account class hasn't been re-declared with the add_signer_via_passkey surface yet, so on Starknet mainnet use multi-device / recovery instead. Solana and Stellar route passkey approval through their relayers and are not affected.

Non-custodial

A passkey strengthens the wallet without introducing custody:

  • Only the passkey's public key is on-chain. The credential stays with the OS authenticator; Cavos never receives it.
  • add_signer_via_passkey is a public external that is authorized solely by the embedded WebAuthn assertion — it still requires a valid signature. Cavos sponsors the gas but cannot add a signer without the passkey's assertion.
  • Removing the passkey is an on-chain remove_approver, gated to the account like any signer change.

Combine it with the other two fallbacks for the strongest setup:

  1. Passkey (this page) — self-serve approval on any new device.
  2. Multiple devices (multi-device) — approve from an existing, logged-in device.
  3. Passphrase backup (recovery) — the last resort when all devices and the passkey are gone.

See Concepts → non-custodial model for the full security rationale.

On this page