Multi-device
Add a new device to an existing wallet via approval from a registered device.
A wallet's address is looked up in the registry by (userId, appId, chain, network),
so the same user on a new device lands on the same wallet. But that new
device has its own key, which isn't yet an authorized signer on the account.
Adding it requires approval from a device that already controls the wallet.
Chain-native account state—not the Cavos backend—is the authority.
Detecting a new device
When Cavos.connect runs on a device whose key isn't an authorized signer, it
returns status: "needs-device-approval" and records a pending request:
const cavos = await Cavos.connect({ /* ... */ });
if (cavos.status === "needs-device-approval") {
// This device requested addition. Prompt the user to approve from an
// existing device. Poll for approval, then reconnect.
console.log(cavos.pendingRequestId);
}Under the hood, when an appId is set, the kit uses the hosted
HttpRecoveryClient to file a device-addition request, which notifies the user
(styled as a login-approval prompt).
Approve with a passkey instead
If the user enrolled a passkey after signup, they can self-approve from the new browser with one prompt — no email round-trip, no second device:
import { Cavos, PasskeySigner } from "@cavos/kit";
const cavos = await Cavos.connect({ /* same identity + appSalt... */ });
if (cavos.status === "needs-device-approval") {
await cavos.approveThisDeviceWithPasskey({ passkey: new PasskeySigner() });
// cavos is now ready.
}approveThisDeviceWithPasskey is the recommended path for users who have a
passkey; the hosted approval page below is the fallback for those who don't.
Starknet mainnet caveat. Passkey approval currently works on Sepolia only. On Starknet mainnet, fall back to the hosted approval page (or recovery codes). Solana and Stellar route passkey approval through their relayers and are not affected.
Host the approval and revocation pages
Cavos does not host these pages. Each integrating app serves its own
/approve-device and /revoke-device routes and registers the origin in the
dashboard. Approving a device signs an on-chain add_signer paid for with the
app's own paymaster key — Cavos never sponsors gas for third-party apps.
When a device-addition request is filed, the backend emails the owner a link to
${device_approval_url}/approve-device?request=<id>. The "a new device was
added" notice links to /revoke-device?request=<id> the same way. Set
device_approval_url in your app's dashboard (or website_url as a fallback) —
if neither is configured, device addition is rejected.
Both pages ship in the SDK:
'use client';
import { RevokeDevicePage } from '@cavos/kit/react';
import { starknetConfig, solanaConfig, stellarConfig } from './config';
export default function Page() {
return <RevokeDevicePage configs={[starknetConfig, solanaConfig, stellarConfig]} />;
}ApproveDevicePage is identical in shape. Pass every chain your app supports:
a device signer lives on exactly one chain, and the component selects the config
matching the request rather than guessing. Hand it only one and a request for a
different chain fails loudly instead of asking the wrong ledger.
Bringing your own UI
The default markup is plain semantic HTML with no styles, so it inherits your
CSS. Pass a function as children to replace it entirely and keep the wiring:
<RevokeDevicePage configs={[starknetConfig, solanaConfig, stellarConfig]}>
{({ status, request, isSelf, error, signIn, submit }) => (
/* your markup */
)}
</RevokeDevicePage>status moves through loading → error | needs-signin → ready → submitting → done. isSelf is true when the request targets the device being used, which
cannot be revoked from itself — surface it before the button rather than as a
failure after.
The component owns three things that are easy to get wrong by hand: it stashes the request id so it survives the sign-in redirect (which replaces the query string), it mounts the provider for the request's own chain, and it handles the expired and already-completed states. If you write these pages yourself, those are the parts to get right.
Build a device manager
The two pages cover the email-link flows. For an in-app "your devices" screen,
read and revoke directly from useCavos():
const { listDevices, removeSigner } = useCavos();
const devices = await listDevices(); // [{ x, y }] — authorized device keys
await removeSigner(devices[1]); // sponsored remove_signerremoveSigner must run from a device that is already authorized and cannot
revoke itself — the same rule the revocation page surfaces as isSelf.
Stellar works differently. Its devices are envelope slots, not on-chain
signer pubkeys, so listDevices() / removeSigner() on the hook throw there.
Call wallet.listDevices() (slot ids) and wallet.removeDevice({ slotId, … })
after narrowing on wallet.chain === "stellar", and read
Stellar → Manage devices first: revocation there
rotates the control key and evicts every other device.
The non-custodial guarantee
The backend can request, notify, and relay a device addition — it can never
authorize one. The account contract gates add_signer so it only succeeds
when called by the account itself, which requires a valid signature from an
existing on-chain signer. The backend holds no keys and has no privileged
contract role.
The newSigner pubkey in a pending request is only a proposal for the UI. The
approving device's signature commits to the actual pubkey passed to add_signer,
so tampering with the request produces an inert, unsigned record. Always show the
user the device label and a pubkey fingerprint before they approve.
Encourage multiple devices early. If a user only ever has one device and loses it, they fall back to recovery codes. Adding a second device at onboarding makes recovery effortless.
See Concepts → non-custodial model for the full security rationale.