Skip to main content
This is the written selection record for the passkey owner of a Stellar spend vault. It is dated 2026-10-01 and it records one decision: which smart-account contract may own an AgentSpendPolicy vault on behalf of a person who holds a passkey and no seed phrase.
The timing, stated plainly. The plan called for a short spike in week 1 with a written record at the end of it. The technical spike ran on 2026-09-19, later than planned, and its on-chain record is soroban/releases/testnet-passkey-owner-2026-09-19.json. The written record, this page, was completed on 2026-10-01. The spike proved the contract path works; it did not settle the audit question, and its signatures came from a software P-256 key in our own script, so nothing from that run is evidence of a device passkey.

The bar

A candidate is selected only if it meets all four parts. They are quoted from the statement of work:
  1. the account contract carries a third-party audit report at a named version;
  2. the surrounding SDK is maintained;
  3. the authorization model supports a single fixed owner;
  4. the account contract exposes no entrypoint that can invoke an arbitrary contract without its own authorization.
Part 4 exists because of our own audit finding A1-02. The vault’s owner check is owner.require_auth(), and on Soroban a contract that is the direct invoker of the vault is authorized without any signature at all. If the owner were an account contract with an entrypoint that forwards an arbitrary call without first demanding its own authorization, anyone could call that entrypoint and act as the vault’s owner. The audit of the contract and the maturity of the SDK are assessed separately below. A well-audited contract driven by a young SDK is a different risk from an unaudited contract, and folding the two into one verdict would hide which one we are carrying.

The candidates

(a) OpenZeppelin smart account and WebAuthn verifier

The account contract and the WebAuthn verifier come from OpenZeppelin/stellar-contracts: the library in packages/accounts and the deployable crates in examples/multisig-smart-account. The browser side is stellar/smart-account-kit 0.8.0, a TypeScript SDK over that contract. Part 1, the audit: pass, with three caveats. The report is “Stellar Contracts RC v0.7.0 Audit” by OpenZeppelin Security, dated 2026-04-06 on its cover, fieldwork 2026-03-02 to 2026-03-19, published 2026-04-28. In the repository it is the file Stellar Contracts Library v0.7.0 Audit.pdf in the audits directory. It is a differential audit of HEAD commit 239a2a7 against BASE 82f9711, and its scope lists packages/accounts/src/smart_account/ (mod.rs, storage.rs), packages/accounts/src/verifiers/ (ed25519.rs, mod.rs, webauthn.rs) and packages/accounts/src/policies/. It reports 21 issues, 18 resolved, including one high (H-01, rule selection downgrade after signature collection, resolved). The caveats, none of which we can make go away:
  • Third party to us, not independent of the author. OpenZeppelin Security audited OpenZeppelin’s own library. It is a third party relative to A-Identity, which is what the bar asks, and it is not an independent firm relative to the code’s author. A reader who needs the second property should know it is absent.
  • The published deployment is not the audited version. The kit’s published account wasm 1b5f4534...785a is built from commit 1e513890, which is 46 commits after the v0.7.0 tag and is contained in no release tag. The kit says so itself: its SECURITY.md states that the deployed artifacts use a later source revision than the audit.
  • The deployable crate is not in the audit’s file list. The contract a wallet actually deploys is examples/multisig-smart-account/account, a thin wrapper (constructor, batch_add_signer, __check_auth, upgrade) around the audited library. The audit scope lists packages/ only. The same is true of the WebAuthn verifier crate, which wraps the audited verifiers/webauthn.rs.
The decision that answers the second caveat. We do not use the kit’s published wasm for the vault owner on testnet. We build the account and the WebAuthn verifier ourselves from the tag v0.7.2. We did not have to upload them: when we went to, the account and both verifier code entries were already on testnet with exactly our build hashes, uploaded by someone else, so the upload was skipped. A code entry is named by the sha256 of its bytes, so the bytes on the ledger are the bytes we built. We extended the entries’ TTLs and deployed our own two verifier instances, the WebAuthn verifier CABPDJH4... (tx 8c6894a8...) and the ed25519 verifier CCKPFIAA... (tx 33642444...); the receipt is receipt-testnet-deploy-2026-10-01.json in the directory linked below. The v0.7.2 tag is on the audited release line: packages/accounts/src is byte-identical between v0.7.0 and v0.7.2 (we diffed it; the only changes under packages/accounts are a build-metadata block in Cargo.toml and the README). So “the audit at a named version” becomes something a reader can check against a wasm hash anyone can reproduce, rather than something we assert about a binary someone else built. The build recipe, the toolchain and the resulting hashes live in soroban/third-party/openzeppelin-smart-account/; for the hashes, see the build receipt in that directory. The third caveat stays: the example wrapper is still outside the file list, and it is small enough to read in full, which we did (see part 4). On pubnet the passkey page still uses OpenZeppelin’s published deployment, account wasm 1b5f4534...785a and WebAuthn verifier CB7HENHJ..., and labels it as theirs. Only the testnet passkey owner is built by us. Part 2, the SDK: maintained, and immature. smart-account-kit is published under the stellar GitHub organisation. npm lists 14 releases from 2026-03-03 (0.2.10) to 2026-09-08 (0.8.0), the version we use, and the repository was last pushed on 2026-09-18. That is maintained. It is also young, and we carry it as a separate SDK risk:
  • it is a 0.x package with one npm maintainer;
  • its own README and SECURITY.md call it unaudited integration software: “The SDK, demo, relayer proxy, and indexer integration have not received an independent third-party security audit”;
  • since 2026-09-18 (its pull request #15) it states that it is outside the Stellar Development Foundation bug bounty. The contracts underneath have a bounty of their own: OpenZeppelin’s Immunefi program covers the packages folder of the latest release.
Part 3, a single fixed owner: pass. The account’s constructor installs exactly one Default context rule with exactly the signers and policies it is given. Constructed with one WebAuthn signer and no policies, it is a 1-of-1 account: that passkey and nothing else. Every function that changes the account’s rules, signers, policies or code, add_context_rule, update_context_rule_name, update_context_rule_valid_until, remove_context_rule, add_signer, remove_signer, add_policy, remove_policy, the example’s batch_add_signer and upgrade, begins with current_contract_address().require_auth(), so only the account’s own authorization, which here means the passkey, can change its signer set. Part 4, no unauthorized arbitrary call: pass. The account built from v0.7.2 exports 17 functions. We read each one: execute is the only entrypoint that calls an arbitrary contract, and it demands the account’s own authorization before it does (mod.rs, lines 509 to 513 at v0.7.2). So the account can be the direct invoker of the vault only after the passkey has signed for that exact invocation, which is the property A1-02 needs. The audit finding closest to this part is M-06, “Arbitrary External Calls Possible in do_check_auth”: the authorization path called the verifier contract of every signer a caller appended, including ones no rule needed. The report marks it resolved in pull requests #656 and #667; #667 (“authenticate after matching signers”) is in the v0.7.0 history, and we did not find a commit that names #656. That finding concerns calls made during authorization, not a path around it. Known issues we disclose rather than discount.
  • OpenZeppelin issue #876: the digest a signer commits to is not bound to the smart account, so a signature collected for one account would also verify on another account that lists the same external key with the same nonce, expiry and invocation. It was fixed by pull request #868 on 2026-09-10, and on 2026-10-01 that fix sits on the v0.9.0 branch and in no release tag, so it is not in v0.7.2. For our case the risk is low: each passkey is enrolled into exactly one account, and the vault accepts only its own owner address.
  • M-05 is acknowledged, not resolved: signer keys are stored in raw form, so a key presented in a different but equivalent encoding does not match. A clean enrolment produces one encoding, so this costs usability rather than safety here.
  • The verifier does not check the origin or the RP ID. Its own source says origin validation in clientDataJSON and the rpIdHash check are omitted and left to the authenticator. The chain therefore cannot tell a passkey created on our site from one created elsewhere, or from a software key that produces the same format. That is why the 2026-09-19 software-key run decodes exactly like a real device would, and why it is never presented as one.

(b) passkey-kit

stellar/passkey-kit is maintained (0.19.1 on npm, 2026-09-18). Its README opens with a caution that the smart-wallet contract, SDKs and relayer proxy have not received an independent third-party security audit. It fails part 1, so parts 3 and 4 were not assessed.

(c) soroban-webauthn

stellar-experimental/soroban-webauthn exists, is not archived, and was last pushed on 2026-03-13. Its README says the code is demo material only and has not been audited. It fails part 1.

Decision

GO: the passkey owner of a testnet spend vault is the OpenZeppelin smart account with one WebAuthn signer, built by us from the audited release line at tag v0.7.2, whose code entries were already on testnet with our build hashes, verified through WebAuthn by a verifier instance we deployed. The browser drives it through smart-account-kit 0.8.0, and OpenZeppelin Channels pays the network fee through our relay; the page names the account that paid. On 2026-10-03 this build carried its first transactions: the whole owner path ran once through our relay and deploy route, with software P-256 keys standing in for a device (account deploy, vault deploy, set_policy, freeze, unfreeze, withdraw, and a second passkey added as its own rule that then withdrew on its own). That run is a rehearsal, labeled as one on the proof page, and it is not evidence of a device passkey; its record is testnet-passkey-rehearsal-v072-2026-10-03.json in soroban/releases. The risk that remains, in one place:
  • the auditor is the author’s own security team;
  • the deployable wrapper crates are outside the audit’s file list, read in full by us rather than audited;
  • the SDK is young, self-declared unaudited and outside the SDF bug bounty;
  • issue #876 is unfixed in any release;
  • the chain cannot distinguish a device authenticator from software, so the device claim rests on what the registration and the recording show;
  • one passkey is one signer. Losing every device that holds it loses the owner for good, which is what the recovery page is for.

Sources

Every source below was read on 2026-10-01 (UTC). The commit counts and the source diff between tags were taken from a clone of the OpenZeppelin repository on the same day: git rev-list --count v0.7.0..1e513890 gives 46, and git diff v0.7.0 v0.7.2 -- packages/accounts/src is empty.