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:
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>secureStep | Behavior 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 withenrollPasskeyfromuseCavos()—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.
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:
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 tolocalhost, 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. PassrpIdexplicitly 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_passkeysurface 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_passkeyis 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:
- Passkey (this page) — self-serve approval on any new device.
- Multiple devices (multi-device) — approve from an existing, logged-in device.
- Passphrase backup (recovery) — the last resort when all devices and the passkey are gone.
See Concepts → non-custodial model for the full security rationale.