Building a Passkey-Enabled Smart Wallet on the Stellar Network

Traditional blockchain wallets require users to manage private keys — a major barrier to mainstream adoption. Lost keys mean lost funds, and many security incidents originate from poor key-handling practices.

In this article, we demonstrate how to build a smart wallet powered by passkeys (WebAuthn) for authentication and transaction signing on the Stellar / Soroban blockchain. By leveraging biometric authentication (Touch ID, Face ID, Windows Hello), we enable a seamless, secure user experience — without requiring private keys.

What You’ll Learn

  • Deploying WebAuthn-enabled smart contracts on Soroban
  • Implementing secure client-server WebAuthn flows
  • Creating wallets with passkey authentication
  • Signing blockchain transactions using passkeys
  • Recovering wallets by rotating passkeys via a recovery account
  • Building a complete authentication system without private keys

Prerequisites & project setup

Requirements

For Node.js applications

  • Node.js 24 LTS or higher
  • npm 10.x or higher

For smart contracts

Project structure

soroban-smart-wallet-poc/

├── apps/

│   ├── backend/      # Express.js API server

│   └── web/          # React frontend

├── contracts/

│   ├── factory/      # Wallet factory contract

│   └── smart-wallet/ # WebAuthn-enabled smart walletCode language: PHP (php)

Installation

# Clone repository

git clone https://github.com/CheesecakeLabs/soroban-smart-wallet-poc

cd soroban-smart-wallet-poc

# Install dependencies

npm installCode language: PHP (php)

Architecture overview

Core components

ComponentDescription
BackendExpress.js API for WebAuthn and Soroban interactions
WebReact frontend for wallet operations
ContractsSoroban smart contracts (Factory + Smart Wallet)

Component responsibilities

Backend (API Server)

  • Generates and validates WebAuthn challenges
  • Stores user and passkey data (SQLite)
  • Interacts with Soroban RPC
  • Sponsors transaction fees

Web (Frontend)

  • Wallet UI
  • Initiates WebAuthn browser flows
  • Communicates with backend

Smart contracts

  • Factory Contract: Deploys wallets with deterministic addresses
  • Smart Wallet Contract: Verifies WebAuthn signatures and supports signer rotation

Smart contract deployment

Step 1: Deploy the smart wallet contract

The smart wallet contract must be deployed first as the factory requires its WASM hash.

Configure the source account:

Update the SOURCE_ACCOUNT under contracts/smart-wallet/ to be the recovery account. This account will later be used to enable users to add new passkeys to their wallets (for lost-access recovery).

cd contracts/smart-wallet

<em># Build the contract</em>
make build

<em># Upload the WASM to the network</em>
make uploadCode language: PHP (php)

Important: Note the returned WASM hash, you’ll need it for the factory contract.

Step 2: Deploy the factory contract

cd contracts/factory

Update the following in the factory Makefile:

– SOURCE_ACCOUNT: Can be the same as the smart wallet’s source account

– WASM_HASH: The hash obtained from the smart wallet upload

<em># Build the factory contract</em>
make build
Code language: HTML, XML (xml)
<em># Deploy to the network</em>
make deployCode language: HTML, XML (xml)

Important: Note the returned contract_id for backend configuration.

Step 3: Configure backend environment

Create .env file in apps/backend/:

# Server Configuration
PORT=3000

# WebAuthn Configuration
WEBAUTHN_RP_NAME="Soroban Smart Wallet POC"
WEBAUTHN_RP_ORIGIN="http://localhost:5173"

# Stellar/Soroban Configuration
STELLAR_RPC_URL="https://soroban-testnet.stellar.org"
STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
STELLAR_MAX_FEE="10000000"

# Contract Configuration
WALLET_FACTORY_CONTRACT_ID="C..."  # Factory contract ID from deployment
NATIVE_TOKEN_CONTRACT_ID="CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"

# Wallet Configuration
OPEX_WALLET_SECRET_KEY="S..."       # Sponsors fees and funds wallets (must be funded)
RECOVERY_WALLET_SECRET_KEY="S..."   # Recovery account secret key (starts with S)Code language: PHP (php)
VariableDescription
WALLET_FACTORY_CONTRACT_IDThe deployed factory contract address
OPEX_WALLET_SECRET_KEYSecret key of the wallet that sponsors transaction fees and funds new wallets. Must be a funded testnet wallet
RECOVERY_WALLET_SECRET_KEYSecret key, its public key is set as recovery signer on new wallets. Enables passkey rotation for lost access

WebAuthn client–server flow

WebAuthn requires a challenge–response model in which the server generates and validates cryptographic challenges.

Why are two endpoints required?

  • Server-generated challenges prevent replay attacks
  • Challenges must be stored for validation
  • Cryptographic verification must happen server-side

The challenge-response model

WebAuthn uses a challenge-response authentication model that requires coordination between client and server:

Why split endpoints?

The WebAuthn protocol requires:

  1. Server-generated challenges: The challenge must be created by a trusted server to prevent replay attacks
  2. Challenge storage: The server must store the challenge to validate the response
  3. Response validation: Only the server can verify the cryptographic response

This leads to a two-endpoint pattern for any operation requiring user authentication:

PatternOptions EndpointAction Endpoint
Wallet CreationGET /api/create-wallet-options/:emailPOST /api/create-wallet
TransferGET /api/transfer-optionsPOST /api/transfer
Sign InGET /api/sign-in-options/:emailPOST /api/sign-in
Wallet RecoveryGET /api/recover-wallet-options/:emailPOST /api/recover-wallet

Wallet creation flow

Wallet creation uses the WebAuthn registration flow to create a new passkey and deploy a smart wallet.

Sequence diagram

Step 1: Generate registration options (Backend)

The server generates WebAuthn registration options with a unique challenge:

<em>// apps/backend/src/helpers/webauthn/registration/index.ts</em>

<strong>async</strong> generateOptions(input: WebAuthnRegistrationGenerateOptionsInput): Promise<string> {
  <strong>const</strong> { userIdentifier, userPasskeys, device } = input;

  <strong>const</strong> relyingPartyName = getValueFromEnv("WEBAUTHN_RP_NAME");
  <strong>const</strong> relyingPartyId = <strong>new</strong> URL(getValueFromEnv("WEBAUTHN_RP_ORIGIN")).hostname;

  <strong>const</strong> identifier = userIdentifier.toLowerCase().trim();
  <strong>const</strong> challenge = <strong>this</strong>.webauthnChallengeService.createChallenge();

  <strong>const</strong> options = <strong>await</strong> generateRegistrationOptions({
    rpName: relyingPartyName,
    rpID: relyingPartyId,
    userID: base64url.toBuffer(identifier) <strong>as</strong> Uint8Array<ArrayBuffer>,
    userName: identifier,
    userDisplayName: `${relyingPartyName} | ${device || "My Device"}`,
    challenge: challenge,
    supportedAlgorithmIDs: [-7], <em>// ES256 (secp256r1)</em>
    attestationType: "none",
    excludeCredentials: userPasskeys.map((passkey) <strong>=></strong> ({
      id: passkey.credentialId,
      transports: passkey.transports?.split(","),
    })),
    authenticatorSelection: {
      authenticatorAttachment: "platform",
      residentKey: "preferred",
      userVerification: "required",
    },
    timeout: 120000,
  });

  <em>// Store challenge linked to user identifier for later validation</em>
  <strong>this</strong>.webauthnChallengeService.storeChallenge(identifier, options.challenge);

  <strong>return</strong> JSON.stringify(options);
}Code language: HTML, XML (xml)

Key configuration:

  • supportedAlgorithmIDs: [-7]
  • Uses ES256 (secp256r1) which Soroban supports natively
  • userVerification: “required”
  • Ensures biometric/PIN authentication
  • Challenge stored with userIdentifier for later validation

Step 2: Create passkey (Frontend)

The frontend triggers the browser’s WebAuthn API to create a new passkey:

// apps/web/src/services/create-wallet/index.ts

async handle(input: CreateWalletInput): Promise<CreateWalletOutput> {
  const { email } = input;

  // 1. Get registration options from server (challenge generated by server)
  const { data: registerOptions } = await http.get(
    `/api/create-wallet-options/${email}`
  );
  const optionsJSON = JSON.parse(registerOptions.options_json);

  // 2. Start WebAuthn registration (triggers passkey prompt)
  const { rawResponse: createPasskeyResponse } =
    await this.webauthnService.createPasskey({ optionsJSON });

  // 3. Complete registration on the server (challenge validation)
  const { data: registerResult } = await http.post(`/api/create-wallet`, {
    email,
    registration_response_json: JSON.stringify(createPasskeyResponse),
  });

  return registerResult;
}

The startRegistration method from @simplewebauthn/browser pops up the system passkey prompt:

// apps/web/src/interfaces/webauthn/index.ts

async createPasskey(input: WebAuthnCreatePasskeyInput): Promise<WebAuthnCreatePasskeyResult> {
  this.checkAvailability();

  const rawResponse = await this.webAuthnClient.startRegistration({
    optionsJSON: input.optionsJSON,
  });

  return {
    rawResponse,
    credentialId: rawResponse.id,
  };
}Code language: JavaScript (javascript)

Step 3: Validate and deploy (Backend)

The server validates the WebAuthn response and deploys the smart wallet:

<em>// apps/backend/src/api/create-wallet.ts</em>

<strong>async</strong> handle(payload: { email: string; registrationResponseJSON: string }) {
  <strong>const</strong> { email, registrationResponseJSON } = payload;

  <em>// 1. Verify challenge resolution</em>
  <strong>const</strong> challengeResult = <strong>await</strong> <strong>this</strong>.webauthnRegistrationHelper.complete({
    userIdentifier: email,
    userPasskeys: [],
    registrationResponseJSON,
  });
  <strong>if</strong> (!challengeResult) <strong>throw</strong> <strong>new</strong> Error("Unable to complete passkey registration");

  <em>// 2. Create user and store passkey credentials</em>
  <strong>const</strong> newUser = <strong>await</strong> <strong>this</strong>.userRepository.createUser({
    email,
    walletContractAddress: null,
  });

  <strong>const</strong> newPasskey = challengeResult.newPasskey;
  <strong>await</strong> <strong>this</strong>.passkeyRepository.createPasskey({
    userId: newUser.userId,
    ...newPasskey,
  });

  <em>// 3. Derive deterministic salt from email</em>
  <strong>const</strong> saltBytes = <strong>new</strong> Uint8Array(32);
  <strong>const</strong> identifierBytes = <strong>new</strong> TextEncoder().encode(email.toLowerCase().trim());
  saltBytes.set(identifierBytes.slice(0, Math.min(32, identifierBytes.length)));
  <strong>const</strong> hashedSalt = hash(Buffer.from(saltBytes));

  <em>// 4. Deploy smart wallet via factory</em>
  <strong>const</strong> args: xdr.ScVal[] = [
    ScConvert.bufferToScVal(hashedSalt),
    ScConvert.addressToScVal(<strong>this</strong>.recoveryWalletPublicKey),
    ScConvert.hexPublicKeyToScVal(newPasskey.credentialHexPublicKey),
  ];

  <strong>const</strong> { tx } = <strong>await</strong> <strong>this</strong>.sorobanService.simulateContractOperation({
    contractId: <strong>this</strong>.factoryContractId,
    method: "deploy",
    args,
  });

  <strong>const</strong> preparedTx = <strong>await</strong> <strong>this</strong>.sorobanService.prepareTransaction(tx);
  <strong>const</strong> signedTx = <strong>await</strong> <strong>this</strong>.sorobanService.signTransactionWithSourceAccount(preparedTx);
  <strong>const</strong> sentTx = <strong>await</strong> <strong>this</strong>.sorobanService.sendTransaction(signedTx);

  <em>// 5. Compute and store wallet address</em>
  <strong>const</strong> expectedAddress = <strong>this</strong>.computeWalletAddress(email);
  <strong>await</strong> <strong>this</strong>.userRepository.updateUser(newUser.userId, {
    walletContractAddress: expectedAddress,
  });

  <strong>return</strong> {
    walletAddress: expectedAddress,
    transactionHash: sentTx.txHash,
  };
}Code language: HTML, XML (xml)

If validation succeeds, we guarantee:

  • The passkey was created by the user’s device
  • The user was verified (biometric/PIN)
  • The public key is valid for secp256r1 curve
  • The wallet can be deployed with this passkey as a signer

Token transfer flow

The transfer flow uses WebAuthn authentication (not registration) to sign a blockchain transaction with an existing passkey.

Key Difference from Wallet Creation

AspectWallet CreationTransfer
WebAuthn FlowRegistrationAuthentication
Challenge SourceRandomTransaction hash
PasskeyCreates newUses existing
On-chain ActionDeploy contractSign transaction

Critical Insight: For transfers, the WebAuthn challenge must be derived from the Soroban transaction simulation. This ensures the signature is bound to the specific transaction being authorized.

Sequence diagram

Step 1: Generate transfer options (Backend)

The challenge is derived from the transaction simulation:

<em>// apps/backend/src/api/transfer-options.ts</em>

<strong>async</strong> handle(payload: { fromWalletAddress: string; toWalletAddress: string; amount: number }) {
  <strong>const</strong> { fromWalletAddress, toWalletAddress, amount } = payload;

  <strong>const</strong> user = <strong>await</strong> getUserWithPasskeysByWalletAddress(
    fromWalletAddress,
    <strong>this</strong>.userRepository,
    <strong>this</strong>.passkeyRepository
  );

  <em>// 1. Build transfer transaction args</em>
  <strong>const</strong> args: xdr.ScVal[] = [
    ScConvert.accountIdToScVal(fromWalletAddress),
    ScConvert.accountIdToScVal(toWalletAddress),
    ScConvert.stringToScVal(ScConvert.stringToPaddedString(amount.toString())),
  ];

  <em>// 2. Simulate the transaction</em>
  <strong>const</strong> { tx, simulationResponse } = <strong>await</strong> <strong>this</strong>.sorobanService.simulateContractOperation({
    contractId: <strong>this</strong>.nativeTokenContractId,
    method: "transfer",
    args,
  });

  <em>// 3. Generate challenge from TX simulation (CRITICAL!)</em>
  <strong>const</strong> challenge = <strong>await</strong> <strong>this</strong>.sorobanService.generateWebAuthnChallenge({
    contractId: <strong>this</strong>.nativeTokenContractId,
    simulationResponse,
    signer: { addressId: user.walletContractAddress! },
  });

  <em>// 4. Generate authentication options with TX-linked challenge</em>
  <strong>const</strong> options = <strong>await</strong> <strong>this</strong>.webauthnAuthenticationHelper.generateOptions({
    type: "raw",
    user,
    customChallenge: challenge,
    customMetadata: {
      type: "soroban",
      tx: tx,
      simulationResponse: simulationResponse,
    },
  });

  <strong>return</strong> { options_json: options };
}Code language: HTML, XML (xml)

Key Points:

  • Challenge is generated from transaction simulation, not random
  • TX and simulation are stored as metadata for later retrieval
  • The challenge binds the signature to this specific transaction

Step 2: Authenticate with Passkey (Frontend)

<em>// apps/web/src/services/transfer/index.ts</em>

<strong>async</strong> handle(input: TransferInput): Promise<TransferOutput> {
  <strong>const</strong> { fromWalletAddress, toWalletAddress, amount } = input;

  <em>// 1. Get transfer options from server (challenge = TX hash)</em>
  <strong>const</strong> { data: transferOptions } = <strong>await</strong> http.get("/api/transfer-options", {
    params: {
      from_wallet_address: fromWalletAddress,
      to_wallet_address: toWalletAddress,
      amount,
    },
  });
  <strong>const</strong> optionsJSON = JSON.parse(transferOptions.options_json);

  <em>// 2. Authenticate with passkey (signs the TX-derived challenge)</em>
  <strong>const</strong> { rawResponse: authenticationResponse } =
    <strong>await</strong> <strong>this</strong>.webauthnService.authenticateWithPasskey({ optionsJSON });

  <em>// 3. Complete transfer on server</em>
  <strong>const</strong> { data: transferResult } = <strong>await</strong> http.post(`/api/transfer`, {
    from_wallet_address: fromWalletAddress,
    to_wallet_address: toWalletAddress,
    amount,
    authentication_response_json: JSON.stringify(authenticationResponse),
  });

  <strong>return</strong> transferResult;
}Code language: HTML, XML (xml)

Step 3: Validate and submit transaction (Backend)

<em>// apps/backend/src/api/transfer.ts</em>

<strong>async</strong> handle(payload: {
  fromWalletAddress: string;
  toWalletAddress: string;
  amount: number;
  authenticationResponseJSON: string;
}) {
  <strong>const</strong> { fromWalletAddress, authenticationResponseJSON } = payload;

  <strong>const</strong> user = <strong>await</strong> getUserWithPasskeysByWalletAddress(<em>/*...*/</em>);

  <em>// 1. Verify authentication challenge</em>
  <strong>const</strong> verifyAuth = <strong>await</strong> <strong>this</strong>.webauthnAuthenticationHelper.complete({
    type: "raw",
    user,
    authenticationResponseJSON,
  });
  <strong>if</strong> (!verifyAuth) <strong>throw</strong> <strong>new</strong> Error("Unable to complete passkey authentication");

  <em>// 2. Retrieve TX metadata stored during options generation</em>
  <strong>const</strong> { customMetadata } = verifyAuth;
  <strong>if</strong> (!customMetadata || customMetadata.type !== "soroban") {
    <strong>throw</strong> <strong>new</strong> Error("Unable to find Soroban custom metadata");
  }

  <em>// 3. Build contract signer with passkey signature</em>
  <strong>const</strong> passkeySigner: ContractSigner = {
    addressId: user.walletContractAddress!,
    methodOptions: {
      method: "webauthn",
      options: {
        clientDataJSON: verifyAuth.clientDataJSON,
        authenticatorData: verifyAuth.authenticatorData,
        signature: verifyAuth.compactSignature,
      },
    },
  };

  <em>// 4. Sign auth entries with passkey signature</em>
  <strong>const</strong> tx = <strong>await</strong> <strong>this</strong>.sorobanService.signAuthEntries({
    contractId: <strong>this</strong>.nativeTokenContractId,
    tx: customMetadata.tx,
    simulationResponse: customMetadata.simulationResponse,
    signers: [passkeySigner],
  });

  <em>// 5. Re-simulate, prepare, sign with source account, and submit</em>
  <strong>await</strong> <strong>this</strong>.sorobanService.simulateTransaction(tx);
  <strong>const</strong> preparedTx = <strong>await</strong> <strong>this</strong>.sorobanService.prepareTransaction(tx);
  <strong>const</strong> signedTx = <strong>await</strong> <strong>this</strong>.sorobanService.signTransactionWithSourceAccount(preparedTx);
  <strong>const</strong> sentTx = <strong>await</strong> <strong>this</strong>.sorobanService.sendTransaction(signedTx);

  <strong>return</strong> { transactionHash: sentTx.txHash };
}Code language: HTML, XML (xml)

If validation succeeds:

  • The passkey signature is valid for the TX-derived challenge
  • The user owns the passkey associated with the wallet
  • The signature can be attached to the transaction as authorization
  • The smart wallet’s __check_auth will verify the WebAuthn signature on-chain

Sign-in flow

The sign-in flow authenticates users using their existing passkeys without any on-chain interaction.

Why implement sign-In?

Even though sign-in doesn’t require blockchain transactions, it’s important for:

  • Session management – Verify user identity before showing wallet data
  • Consistent UX – Users expect to “log in” to applications
  • Security – Ensures only passkey owners can access wallet features
  • Future features – Some app features may not require on-chain actions

Flow Comparison

AspectSign-InTransfer
ChallengeRandom server-generatedTX-derived
On-chainNoYes
PurposeSession authTX authorization

Implementation

Sign-In options (Backend):

// apps/backend/src/api/sign-in-options.ts

async handle(payload: { email: string }) {
  const { email } = payload;

  const userWithPasskeys = await getUserWithPasskeysByEmail(/*...*/);
  if (userWithPasskeys.passkeys.length === 0) {
    throw new Error("User has no passkeys");
  }

  // Generate standard authentication options (random challenge)
  const optionsJSON = await this.webauthnAuthenticationHelper.generateOptions({
    type: "standard"// Uses random challenge, not TX-derived
    user: userWithPasskeys,
  });

  return { options_json: optionsJSON };
}

Sign-In Completion (Backend):

// apps/backend/src/api/sign-in.ts

async handle(payload: { email: string; authenticationResponseJSON: string }) {
  const { email, authenticationResponseJSON } = payload;

  const userWithPasskeys = await getUserWithPasskeysByEmail(/*...*/);

  // Verify passkey authentication
  const challengeResult = await this.webauthnAuthenticationHelper.complete({
    type: "standard",
    user: userWithPasskeys,
    authenticationResponseJSON,
  });
  if (!challengeResult) throw new Error("Unable to complete passkey authentication");

  // If validation succeeds, user is authenticated
  return { walletAddress: userWithPasskeys.walletContractAddress };
}

Sign-In Service (Frontend):

// apps/web/src/services/sign-in/index.ts

async handle(input: SignInInput): Promise<SignInOutput> {
  const { email } = input;

  // 1. Get authentication options
  const { data: authenticationOptions } = await http.get(
    `/api/sign-in-options/${email}`
  );
  const optionsJSON = JSON.parse(authenticationOptions.options_json);

  // 2. Authenticate with passkey
  const { rawResponse: authenticationResponse } =
    await this.webauthnService.authenticateWithPasskey({ optionsJSON });

  // 3. Complete sign-in
  const { data: signInResult } = await http.post(`/api/sign-in`, {
    email,
    authentication_response_json: JSON.stringify(authenticationResponse),
  });

  return signInResult;
}Code language: JavaScript (javascript)

If validation succeeds:

  • The user owns the passkey registered for this email
  • A session can be safely created
  • Wallet data can be shown to the user

Wallet recovery flow

The recovery flow enables users who have lost access to their passkey to regain control of their wallet by registering a new passkey. This is achieved through a recovery account that was set during wallet creation.

How recovery works

When a smart wallet is deployed, it stores two signers: 

  1. User’s passkey – The primary signer for daily operations
  2. Recovery account – A backup signer that can rotate the passkey

The recovery account (controlled by the backend) can call the rotate_signer method on the smart wallet contract to replace the old passkey with a new one.

Smart contract: rotate_signer

<em>// contracts/smart-wallet/src/lib.rs</em>

<strong>impl</strong> Recovery <strong>for</strong> AccountContract {
    <strong>fn</strong> rotate_signer(env: Env, new_signer: BytesN<65>) -> Result<(), RecoveryError> {
        <em>// Only the recovery account can call this method</em>
        <strong>let</strong> recovery = env
            .storage()
            .instance()
            .get::<_, Address>(&DataKey::Recovery)
            .ok_or(RecoveryError::RecoveryNotSet)?;
        recovery.require_auth();

        <em>// Replace the signer with the new passkey public key</em>
        env.storage().instance().set(&DataKey::Signer, &new_signer);

        Ok(())
    }
}Code language: HTML, XML (xml)

Flow comparison

AspectWallet CreationWallet Recovery
WebAuthn FlowRegistrationRegistration
User Must ExistNoYes
On-chain ActionDeploy contractRotate signer
SignerUser’s passkeyRecovery account

Sequence diagram

Step 1: Generate Recovery Options (Backend)

The server generates WebAuthn registration options (to create a new passkey):

<em>// apps/backend/src/api/recover-wallet-options.ts</em>

<strong>async</strong> handle(payload: { email: string }) {
  <strong>const</strong> { email } = payload;

  <em>// User must already exist with a wallet</em>
  <strong>const</strong> user = <strong>await</strong> getUserWithPasskeysByEmail(
    email,
    <strong>this</strong>.userRepository,
    <strong>this</strong>.passkeyRepository
  );
  <strong>if</strong> (!user || user.walletContractAddress === <strong>null</strong>) {
    <strong>throw</strong> <strong>new</strong> Error("User not found or wallet contract address not set");
  }

  <em>// Generate registration options (to create a NEW passkey)</em>
  <strong>const</strong> optionsJSON = <strong>await</strong> <strong>this</strong>.webauthnRegistrationHelper.generateOptions({
    userIdentifier: email,
    userPasskeys: user.passkeys,  <em>// Exclude existing passkeys</em>
  });

  <strong>return</strong> { options_json: optionsJSON };
}Code language: HTML, XML (xml)

Step 2: Create New Passkey (Frontend)

The frontend triggers the passkey registration flow:

<em>// apps/web/src/services/recover-wallet/index.ts</em>

<strong>async</strong> handle(input: RecoverWalletInput): Promise<RecoverWalletOutput> {
  <strong>const</strong> { email } = input;

  <em>// 1. Get recovery options from server</em>
  <strong>const</strong> { data: recoverOptions } = <strong>await</strong> http.get(
    `/api/recover-wallet-options/${email}`
  );
  <strong>const</strong> optionsJSON = JSON.parse(recoverOptions.options_json);

  <em>// 2. Start WebAuthn registration (creates NEW passkey)</em>
  <strong>const</strong> { rawResponse: recoverPasskeyResponse } =
    <strong>await</strong> <strong>this</strong>.webauthnService.createPasskey({ optionsJSON });

  <em>// 3. Complete recovery on server</em>
  <strong>const</strong> { data: recoverResult } = <strong>await</strong> http.post(`/api/recover-wallet`, {
    email,
    registration_response_json: JSON.stringify(recoverPasskeyResponse),
  });

  <strong>return</strong> recoverResult;
}Code language: HTML, XML (xml)

Step 3: Rotate Signer with Recovery Account (Backend)

The server validates the new passkey and uses the recovery account to sign the rotate_signer transaction:

<em>// apps/backend/src/api/recover-wallet.ts</em>

<strong>async</strong> handle(payload: { email: string; registrationResponseJSON: string }) {
  <strong>const</strong> { email, registrationResponseJSON } = payload;

  <strong>const</strong> user = <strong>await</strong> getUserWithPasskeysByEmail(<em>/*...*/</em>);
  <strong>if</strong> (!user || !user.walletContractAddress) {
    <strong>throw</strong> <strong>new</strong> Error("User not found or wallet contract address not set");
  }

  <em>// 1. Validate new passkey registration</em>
  <strong>const</strong> challengeResult = <strong>await</strong> <strong>this</strong>.webauthnRegistrationHelper.complete({
    userIdentifier: email,
    userPasskeys: user.passkeys,
    registrationResponseJSON,
  });
  <strong>if</strong> (!challengeResult) <strong>throw</strong> <strong>new</strong> Error("Unable to complete passkey registration");

  <em>// 2. Store the new passkey in the database</em>
  <strong>const</strong> newPasskey = challengeResult.newPasskey;
  <strong>await</strong> <strong>this</strong>.passkeyRepository.createPasskey({
    userId: user.userId,
    ...newPasskey,
  });

  <em>// 3. Prepare rotate_signer transaction</em>
  <strong>const</strong> args: xdr.ScVal[] = [
    ScConvert.hexPublicKeyToScVal(newPasskey.credentialHexPublicKey),
  ];

  <em>// 4. Recovery account signs the transaction (not the user!)</em>
  <strong>const</strong> signers: ContractSigner[] = [
    {
      addressId: <strong>this</strong>.recoveryWalletKeypair.publicKey(),
      methodOptions: {
        method: "keypair",
        options: {
          secret: <strong>this</strong>.recoveryWalletKeypair.secret(),
        },
      },
    },
  ];

  <em>// 5. Simulate and execute the transaction</em>
  <strong>const</strong> { tx } = <strong>await</strong> <strong>this</strong>.sorobanService.simulateContractOperation({
    contractId: user.walletContractAddress,  <em>// Call the user's wallet contract</em>
    method: "rotate_signer",
    args,
    signers,
  });

  <strong>const</strong> preparedTx = <strong>await</strong> <strong>this</strong>.sorobanService.prepareTransaction(tx);
  <strong>const</strong> signedTx = <strong>await</strong> <strong>this</strong>.sorobanService.signTransactionWithSourceAccount(preparedTx);
  <strong>const</strong> sentTx = <strong>await</strong> <strong>this</strong>.sorobanService.sendTransaction(signedTx);

  <strong>return</strong> {
    walletAddress: user.walletContractAddress,
    transactionHash: sentTx.txHash,
  };
}Code language: HTML, XML (xml)

Key points:

  • The recovery account (stored in RECOVERY_WALLET_SECRET_KEY) signs the transaction
  • The user creates a new passkey via WebAuthn registration
  • The smart wallet’s signer is rotated on-chain to the new passkey
  • The old passkey becomes invalid for future transactions

Security considerations

AspectImplication
Recovery account is centralizedThe backend controls the recovery key. In production, consider multi-sig or time-locked recovery
User identity verificationThis PoC only requires email. Production systems should add additional verification (KYC, security questions, etc.)
Old passkey invalidationOnce rotated, the old passkey cannot sign transactions, preventing malicious use of compromised credentials

Additional features

The PoC includes additional features worth exploring:

Fund wallet

Transfers testnet XLM from the operations wallet to newly created wallets. This enables users to start using their wallet immediately after creation:

POST /api/fund-wallet

Body: { wallet_address: string }

The operations wallet (OPEX_WALLET_SECRET_KEY) sponsors this funding, allowing users to interact with the blockchain without first acquiring tokens.

Get balance

Retrieves the native token balance for a wallet:

GET /api/balance?wallet_address=C...

This endpoint queries the Soroban RPC to fetch the current balance of the smart wallet contract.

Conclusion

This guide demonstrated how to build WebAuthn-enabled smart wallets on Soroban, eliminating the need for users to manage private keys. Key takeaways:

Architecture decisions

  1. Two-endpoint pattern – Required for WebAuthn challenge-response flow
  2. Server-side challenge storage – Links user identity to authentication attempts
  3. TX-derived challenges for transfers – Binds signatures to specific transactions
  4. Fee sponsorship – Operations wallet pays transaction fees for better UX
  5. Recovery account – Centralized backup signer enables passkey rotation for lost access

Security guarantees

  • Passkey security – Private key never leaves the device
  • Biometric verification – User presence required for each operation
  • Challenge binding – Signatures are tied to specific operations
  • On-chain verification – Smart contract validates WebAuthn signatures

What’s next?

  • Session management by Tokens – Enhances system security
  • Multi-passkey support – Allow multiple devices per wallet
  • Decentralized recovery – Replace centralized recovery with multi-sig or time-locked mechanisms
  • Gas abstraction – Advanced fee sponsorship strategies
  • Social recovery – Multiple recovery signers with threshold signatures

Try it yourself

The complete proof of concept is available here!

Feel free to:

  • Deploy your own contracts on testnet
  • Experiment with wallet creation, transfer, and recovery flows
  • Test the passkey rotation mechanism – Extend the PoC with additional features

References

About the author.

Roberson Costa
Roberson Costa

Roberson works on projects across mobile, backend, frontend, blockchain, and infrastructure, helping deliver scalable and reliable solutions for global clients.