Hardware-isolated recovery
Restore a Solana or Stellar MasterDEK — or schedule a Starknet add_signer — by signing in again. Verified inside an attested Nitro enclave. Cavos never holds a key that can spend.
A recovery code works, but it asks the user to keep a secret safe forever, and most will not. Hardware-isolated recovery lets them recover the way they expect to: by signing in again with Google, Apple, or an email link.
The obvious way to build that would be for Cavos to hold a key and hand it over once a login checks out. That would make Cavos custodial and every Cavos employee a target. Instead the check runs inside an AWS Nitro Enclave — an isolated environment whose exact software is measured by hardware and attested by AWS — holding a key that the chain itself restricts to a single, delayed, cancellable action.
Availability. Hardware-isolated recovery is opt-in per environment, not
on by default. The SDK path ships in @cavos/kit, but the enclave it talks to
has to be enabled for your app and environment in the
dashboard before socialRecovery: true or
deviceApproval: "enclave" resolves — ask us to turn it on. Every other
recovery path in Recovery works without it.
Call this hardware-isolated, non-custodial recovery, never trustless recovery. No Cavos employee holds a key that can move funds, but the measured enclave, AWS's attestation and KMS, the approved image-upgrade policy, and — for the email provider — your Firebase identity project all remain inside the trust model. Section Why this is still self-custodial is explicit about both halves.
Solana, Stellar, and Starknet — different jobs. On Solana and Stellar the
enclave seals and unwraps the MasterDEK. It does not sign transfers. On
Starknet it still holds a restricted recovery authority that can only schedule
add_signer. Set deviceApproval: "enclave" (or socialRecovery: true).
Passkey approval is a different, single-chain path.
How it works
The feature has two moments: enrolment, once, on a device the user already controls, and recovery, later, on a device that has nothing.
Enrolment
- The user signs in. The provider's
id_tokenstays in memory only — it is never persisted, logged, or sent to Cavos in the clear. - The SDK asks for the enclave's attestation document: a statement signed by the AWS Nitro Attestation PKI describing exactly which image is running. It verifies the signature against the AWS root certificate pinned in the SDK, checks the measurement against the ones this release accepts, and aborts if anything differs.
- Satisfied, the browser encrypts the token directly to an ephemeral key belonging to that enclave. The key is read from inside the signed document rather than from the response that carried it, so there is nothing to cross-check: substituting a key would mean forging an AWS signature. The Cavos control plane relays ciphertext it cannot read.
- Inside, the enclave verifies the token. On Solana and Stellar it seals
the MasterDEK (
dek_enroll) so a later device can unwrap it. On Starknet it generates a recovery keypair, seals the private half, and returns the public key. - Starknet registers that public key on-chain as a restricted recovery authority. Native Solana/Stellar have no extra on-chain recovery signer — the wrap is the restore path. Reconnect reseals if this device already has the DEK locally, so a first enroll that failed after claiming the address still recovers. From 0.2.2 that reseal runs in the background: the wallet is ready before it finishes, and a reseal that fails is logged, never a failed login.
Recovery
The user signs in on the new device and the same channel is established. The enclave verifies the token. On Solana/Stellar it unwraps the MasterDEK to this device's wrap key. On Starknet it signs one message: an authorization for this specific new device. The chain (Starknet) or the derived spend key (Solana/Stellar) does the rest.
The result is worth stating plainly: an attacker who fully compromises the Cavos backend gets ciphertext they cannot decrypt and an on-chain authority they cannot use. An attacker who somehow ran code inside the enclave still could not act outside the single delayed action the contract permits.
What the enclave verifies
Inside, before it will sign anything, Cavos checks the provider signature, issuer, audience, token fingerprint, configured provider, and the enrolled subject — the recovery only proceeds for the identity that enrolled.
The authentication must be no more than five minutes old. Google and Apple
tokens must carry an OIDC nonce, which marks a token as minted for an
interactive sign-in; what binds a token to this session is its SHA-256
fingerprint, committed when the session opens and compared in constant time. The
API stores only a second hash of that fingerprint, so a token can be used once
and replays are rejected. Email requires a verified, recent Firebase email token
obtained through the magic-link flow. OTP and Cavos-signed compatibility tokens
are never accepted.
What the chain enforces
The enclave's authority is not "a key that can sign for the wallet". It is a key the account contract will listen to for exactly one purpose.
| Chain | What the enclave does |
|---|---|
| Solana | Seals / unwraps the MasterDEK. The spend key is HKDF of that DEK. No recovery PDA, no add_signer. |
| Stellar | Same MasterDEK wrap as Solana (cavos-ed25519-stellar-v1). A new device is the same G…, not an extra Horizon signer. |
| Starknet | A separate P-256 authority may only schedule one exact add_signer; nonce, expiry, timelock, cancellation, and finalization are contract-enforced. |
A non-zero timelock turns recovery into something the real owner can veto: the
signer is committed on-chain but inert until the delay passes, and any other
device can cancel it. The SDK stores only the public readyAt timestamp,
resumes after a refresh, and permissionlessly finalizes when the delay expires.
It never persists the social token.
Why this is still self-custodial
"Someone else can restore my wallet" sounds like the opposite of self-custody, so it is worth being precise about what the enclave can and cannot do. Four things hold, and each is enforced by something other than a Cavos promise.
Nobody can read the recovery key — not even the people who run it. The private half is sealed under a key AWS KMS releases only to an enclave whose measured image matches the one that sealed it. There is no console, no debugger, no support tool that reveals it: an enclave started in debug mode reports zeroed measurements, which no pinned policy accepts, and KMS refuses to release the key to it. A Cavos employee asked to hand over a user's key has nothing to hand over.
The key cannot move money on Starknet. The enclave's Starknet key is not a wallet signer. Native Solana/Stellar: the enclave holds a wrap of the MasterDEK, not a hot spender. Compromise of the wrap still means restore of the spend key — that is why attestation pins and the 5-minute token window exist. Compare that with a custodial provider, where the operator's key is the spending key.
Nothing is added without this device holding the DEK (or a Starknet signer). Native enroll is a wrap, not an extra on-chain authority. Starknet still registers the recovery role with a transaction the current device signs.
The real owner can veto a recovery. With a non-zero timelock the scheduled signer is inert until the delay passes and any existing device can cancel it. So even in the worst case — a valid recovery for an identity that is not really the owner — the owner has a window to stop it, on-chain, without asking Cavos.
Put together: the account contract remains the sole authority over who can sign, the enclave holds a narrow permission granted by the user, and the Cavos control plane only relays ciphertext it cannot read. That is what makes this self-custodial — not the fact that a TEE is involved.
What you are still trusting
Self-custodial is not the same as trustless, and the difference is not a formality:
- The measured enclave image. You are trusting that this specific code does what it claims. Pin the digest and review changes to it the way you would review a key rotation.
- AWS. Attestation and KMS are what make the measurement meaningful and the sealed key openable.
- The image-upgrade policy. A new digest is a new trust decision.
- Your Firebase project, if you use the email provider.
The honest summary: breaking this needs a compromised enclave image and the timelock to expire without the owner noticing. It never needs only Cavos.
Classic Stellar is on this path when deviceApproval is "enclave": the
G… is HKDF of the sealed DEK. Grandfathered wallets that never enrolled still
need a passkey or recovery code.
Keep a second factor regardless. Encourage a passkey or a second device, and treat social recovery as one path among several rather than the only one.
Turning it on
Enable it in Dashboard → App → Environments and select exactly one provider. Users never see a three-provider recovery picker: the environment policy decides the single login method shown.
<CavosProvider
config={{
appId,
environment: "production",
chains: ["solana", "stellar"], // native DEK wrap. Add "starknet" for the
// on-chain add_signer authority as well.
defaultChain: "solana",
network: "mainnet",
appSalt: "my-app",
paymasterApiKey, // for Starknet gasless
socialRecovery: true,
}}
modal={{ appName: "My App" }}
>
{children}
</CavosProvider>That is the whole integration. <CavosProvider> enrolls after a fresh login
when this device has the DEK (Solana/Stellar reseal in the background; Starknet
still confirms on-chain) and unwraps on a device that does not. Watch
walletStatus.isSocialRecovering. A 409 not_enrolled on a new phone means the
creating device never sealed — open that wallet once more on the original
device.
The attestation pin
socialRecovery: true verifies the enclave against measurements pinned in the
package you installed (DEFAULT_SOCIAL_RECOVERY_ATTESTATION).
Those measurements are the answer to "which code am I trusting with my users'
recovery?", and they must not come from the service being checked. If
cavos.xyz told the client which image digest to expect, anyone who compromised
cavos.xyz would answer with their own — and the attestation would prove
nothing. Verifying against values supplied by the thing under verification is
not verification.
Shipping them in a versioned, immutable package keeps the pin outside that control plane while taking it off your hands: you can audit the constant, and you choose when to adopt a new one by upgrading. The attestation itself is signed by AWS, so authenticity is anchored outside Cavos either way — the pin only fixes which workload you are willing to talk to.
Pin your own policy when you run your own enclave:
socialRecovery: {
pcr0: ["f2f81237…", "24ce0009…"], // list to overlap a rollout
}pcr0 is the SHA-384 measurement of the enclave image, the value
nitro-cli build-enclave reports. It accepts a list because the measurement
changes whenever the image is rebuilt: publishing the incoming and outgoing
value together lets a redeploy overlap instead of cutting off apps mid-rollout.
The attested measurement must still match one of them exactly — an empty list
accepts nothing, which is the safe direction.
The enclave build is reproducible, so this is a claim you can check rather than one you have to take on faith. Rebuild the published source and confirm you get the same measurement:
git clone https://github.com/cavos-labs/cavos-recovery
cd cavos-recovery/enclave
./scripts/build-enclave.sh --expect f2f81237…Two independent builds of the same source produce the same PCR0; if they did
not, a published measurement would prove nothing about the published code.
Apps that set socialRecoveryAttestation before this existed keep working
unchanged: an explicit policy always wins over the shipped defaults, so an app
that pinned its own measurements is never silently repointed.
With your own auth
Social recovery works when you authenticate users yourself — Clerk, Auth0, Firebase, your own backend — as long as a real Google or Apple sign-in happens somewhere underneath. Your users never sign in twice: you pass the provider token your login already obtained. Works on Solana, Stellar, and Starknet.
Register your OAuth client ID once in Dashboard → App → Environments → Your OAuth client ID. The enclave then accepts tokens minted for your client instead of only Cavos's.
const { submitSocialRecoveryToken, walletStatus } = useCavos();
// Enrols when this device is ready and recovers when it is not — the wallet's
// state decides, so the same call serves both screens.
async function useRecovery() {
const idToken = await getGoogleIdTokenFromYourAuth();
submitSocialRecoveryToken(idToken);
}Two requirements, both easy to miss:
- Pass the provider's raw
id_token, not your own session JWT. The token has to be verifiable against Google's or Apple's keys, and your session token is signed by you. Passing the wrong one fails immediately with a message saying so. Clerk, Auth0 and Firebase all expose the underlying token, usually behind an explicit call. - Pass a fresh one. The enclave requires the authentication to be under five minutes old. This falls out naturally — enrolment follows a login, recovery happens as the user signs in on the new device — but a token cached from an earlier session is rejected.
Changing the registered client ID never affects wallets already enrolled. The enclave enforces the policy sealed at enrolment, so the setting only applies to enrolments made after it changes.
The email provider still uses the Cavos Firebase project: its audience is the project id and its issuer embeds it, so it is not a per-app value.
How long it takes
There is nothing to call and nothing to warm up. The enclave is a long-lived service, so enrolment and recovery are two synchronous requests.
In practice the enclave accounts for 1–2 seconds. The rest of what a user waits for is the on-chain transaction that installs the recovery authority or the new device signer, which is chain and congestion dependent.
Adding a device is a single transaction when the environment's recovery delay is
zero. With a delay configured it stays two — schedule now, finalize after
readyAt — because that gap is the entire point of the timelock. The contract
computes the deadline from its own clock, so nothing client-side can shorten it.
Earlier releases ran this on Google Confidential Space, which booted a
confidential VM per session. That cost 49–134 seconds of boot for the same
~2 seconds of work, and failed outright when a zone had no capacity. If you are
reading older integration notes that mention prewarming, prewarm(), or a warm
pool, they describe that design; none of it exists now.
Related
- Recovery codes — the self-custodial baseline, no enclave involved.
- Multi-device — approving a new device from an existing one.
- Passkeys — a synced passkey as the approval factor.