---
title: Security audit of w3pk v0.12.3
description: Source-level security review of the w3pk passkey wallet SDK at tag v0.12.3, covering key storage, signing paths, the P-256 smart account, backups, stealth addresses and ZK circuits, with findings and fixes.
date: 2026-11-10
lang: en-US
author: Julien Béranger
model: Claude Fable 5.2
conversation: https://claude.ai/chat/8905ffd0-58f8-42c1-bb1d-aef6452c04bb
source: https://julienberanger.com/w3pk-v0-12-3-security-audit
---

# Security audit of w3pk v0.12.3

## Summary

[w3pk](https://github.com/w3hc/w3pk) v0.12.3 keeps its central promise: a wallet stored on the device cannot be decrypted without a user-verified assertion from the passkey. The at-rest design, the backup format and the hybrid post-quantum encryption are carefully built, and the project's own [threat model](https://github.com/w3hc/w3pk/blob/v0.12.3/docs/SECURITY.md) is unusually candid about what the SDK cannot protect.

The review found no way to recover a mnemonic from storage alone in the default configuration. It did find two high-severity issues outside that core, four medium ones and a set of lower-severity hardening items.

| ID | Severity | Finding |
| --- | --- | --- |
| H-1 | High | `register()` succeeds without PRF, leaving a wallet that is never stored and cannot be backed up |
| H-2 | High | The Merkle circuits accept forged membership proofs (path indices are not constrained to bits) |
| M-1 | Medium | `onBeforeSign` digests can differ from what is signed |
| M-2 | Medium | Stealth addresses are not interoperable with the ScopeLift ERC-5564 SDK |
| M-3 | Medium | Gas values returned by the bundler are signed without bounds |
| M-4 | Medium | The NFT ownership circuit reveals the holder and binds nothing to the prover |
| L-1 to L-9 | Low | See [Low severity](#low-severity) |
| I-1 to I-8 | Info | See [Informational](#informational) |

## Scope and method

- **Target:** tag [`v0.12.3`](https://github.com/w3hc/w3pk/tree/v0.12.3), commit `b5c28e2dcfd0539bd2b94cead1569fd96125bc74`, which is also the version published on [npm](https://www.npmjs.com/package/w3pk) as `latest`.
- **Covered:** all of `src/` (about 17,000 lines of [TypeScript](https://www.typescriptlang.org/)), the two [Solidity](https://soliditylang.org/) contracts in `contracts/src/`, the five [Circom](https://docs.circom.io/) circuits, the build and CI configuration, and the security documentation.
- **Method:** manual source review, with claims traced through the code paths that reach them.

Limits you should know about:

- This was a **static review**. Installing the dependencies was blocked in my environment, so I did not run the test suite, `pnpm audit`, the [Foundry](https://getfoundry.sh/) tests or [Slither](https://github.com/crytic/slither).
- Findings H-1, M-1 and M-3 are derived from reading the code and from [ethers v6](https://docs.ethers.org/v6/) behavior as I know it. They were not executed as proofs of concept.
- M-2 was checked numerically: I recomputed w3pk's own stealth test vector with an independent script under four encodings of the shared secret.
- The [OpenZeppelin](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable) submodule was not fetched. The contract review covers w3pk's own 193 lines and relies on my knowledge of the inherited code.
- The deployed bytecode, `W3PK_ACCOUNT_FACTORY` and `W3PK_ACCOUNT_INIT_CODE_HASH` were not checked against a build.

Severity reflects impact on user funds or privacy, weighted by how likely the condition is. Risks the project already documents and accepts are listed separately in [Documented and accepted risks](#documented-and-accepted-risks).

## Architecture in brief

- A [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) mnemonic is generated in the page and encrypted with [AES-GCM](https://en.wikipedia.org/wiki/Galois/Counter_Mode) under a key derived by [HKDF](https://www.rfc-editor.org/rfc/rfc5869) from the [WebAuthn PRF extension](https://www.w3.org/TR/webauthn-3/#prf-extension) output. Ciphertext and credential metadata live in [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage).
- `STANDARD`, `STRICT` and `YOLO` modes sign with [secp256k1](https://en.bitcoin.it/wiki/Secp256k1) keys derived per origin on a fully hardened [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) path modeled on [SLIP-0013](https://github.com/satoshilabs/slips/blob/master/slip-0013.md).
- `PRIMARY` mode uses the passkey's P-256 key as the owner of `W3pkAccount`, an [ERC-4337](https://eips.ethereum.org/EIPS/eip-4337) account with [ERC-7579](https://eips.ethereum.org/EIPS/eip-7579) modules and [ERC-7739](https://eips.ethereum.org/EIPS/eip-7739) signatures.
- Backups are password files ([Argon2id](https://www.rfc-editor.org/rfc/rfc9106)) or PRF files, optionally split with [Shamir's secret sharing](https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing).
- Optional modules add [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) stealth addresses, a hybrid [ML-KEM-1024](https://csrc.nist.gov/pubs/fips/203/final) + X25519 KEM, [Semaphore](https://semaphore.pse.dev/) identities and [Groth16](https://eprint.iacr.org/2016/260) proofs.

## High severity

### H-1. `register()` succeeds without PRF, leaving a wallet that cannot be stored or backed up

**Where:** [`src/core/sdk.ts`](https://github.com/w3hc/w3pk/blob/v0.12.3/src/core/sdk.ts) lines 364-446, [`src/core/backup-sync.ts`](https://github.com/w3hc/w3pk/blob/v0.12.3/src/core/backup-sync.ts) line 44.

When the authenticator returns no PRF output, `register()` skips `storeWallet()`, keeps the mnemonic in the in-memory session and returns normally. `RegisterResult` carries `address`, `username`, `hasBackup` and `evictionRisk`, and nothing that tells the app the wallet was not persisted.

From that state the user has no way out through the SDK:

- `createBackupFile()` and `setupSocialRecovery()` call `getMnemonicFromSession(true)`, which bypasses the cache, finds no stored wallet and throws `No wallet found`.
- `exportForSync()` reads the stored record and throws `No wallet data found`.
- The mnemonic is never returned by any public method.
- After a page reload or session expiry (one hour by default), `login()` throws `WalletMissingError`.

A user on an authenticator without PRF receives a valid address, may fund it, and loses it permanently. The credential metadata and the passkey remain, so the account also looks registered afterwards.

**Fix:** make `register()` throw `PrfUnsupportedError` when no PRF output is available, and call `signalUnknownCredential` so the orphan passkey is removed. If in-memory wallets must stay possible, return an explicit `stored: false` and let `createBackupFile()` use the live session when no record exists.

### H-2. The Merkle circuits accept forged membership proofs

**Where:** [`membership.circom`](https://github.com/w3hc/w3pk/blob/v0.12.3/src/zk/circuits/membership.circom) line 50, [`nft.circom`](https://github.com/w3hc/w3pk/blob/v0.12.3/src/zk/circuits/nft.circom) line 66.

Both circuits select the left and right inputs of each [Poseidon](https://www.poseidon-hash.info/) node with:

```circom
left[i]  <== currentHash[i] + pathIndices[i] * (pathElements[i] - currentHash[i]);
right[i] <== pathElements[i] + pathIndices[i] * (currentHash[i] - pathElements[i]);
```

`pathIndices[i]` is never constrained to 0 or 1. For any current hash `c` and any real pair of siblings `(L, R)` in the tree, a prover sets `pathElements = L + R - c` and `pathIndices = (L - c) / (L + R - 2c)`. The constraints then give `left = L` and `right = R`, and the proof continues along the honest path to the real root.

Anyone who knows two sibling values of the tree, which is public in every intended use, can prove membership of an arbitrary leaf. The circuits are not in the npm package, but `ZKProofModule.proveMembership()` and `proveNFTOwnership()` are built around them and [`docs/ZK.md`](https://github.com/w3hc/w3pk/blob/v0.12.3/docs/ZK.md) presents them as usable.

**Fix:** add `pathIndices[i] * (pathIndices[i] - 1) === 0;` at every level, or reuse the audited tree templates from [Semaphore](https://github.com/semaphore-protocol/semaphore) or [circomlib](https://github.com/iden3/circomlib).

## Medium severity

### M-1. `onBeforeSign` digests can differ from what is signed

**Where:** [`src/core/eip1193.ts`](https://github.com/w3hc/w3pk/blob/v0.12.3/src/core/eip1193.ts), [`src/core/signing-digests.ts`](https://github.com/w3hc/w3pk/blob/v0.12.3/src/core/signing-digests.ts), [`src/core/sdk.ts`](https://github.com/w3hc/w3pk/blob/v0.12.3/src/core/sdk.ts) line 1036.

The hook added in this release shows [ERC-8213](https://erc8213.eth.limo/) digests before the passkey prompt. For `eth_signTypedData_v4` two gaps break the link between the digest and the signature:

1. **`primaryType` is ignored when signing in EOA modes.** The digest is computed for the request's `primaryType`, using only the types reachable from it. The signature is produced by `wallet.signTypedData(domain, types, message)`, where ethers infers the primary type as the single root of the type graph. A request whose `primaryType` names a nested type, with a message that also carries the root type's fields, displays the digest of the nested struct and signs the root struct. A [Permit2](https://github.com/Uniswap/permit2) `PermitSingle` shown as its inner `PermitDetails` is one example.
2. **Time of check, time of use.** When the typed data is passed as an object, `JSON.stringify(typedMessage)` runs after the hook is awaited. The caller can mutate the message while the user is reading the digests.

`PRIMARY` mode is not affected by the first gap, since it hashes with the requested `primaryType`.

**Fix:** sign with the same reduced type set the digest uses, or reject requests where the inferred root differs from `primaryType`. Snapshot the request (deep copy or serialized string) before calling the hook and sign the snapshot.

### M-2. Stealth addresses are not interoperable with the ScopeLift SDK

**Where:** [`src/stealth/crypto.ts`](https://github.com/w3hc/w3pk/blob/v0.12.3/src/stealth/crypto.ts) lines 305-325.

`computeSharedSecret()` returns ethers' 65-byte uncompressed point (`0x04 || x || y`) and the code hashes all of it, although the comment says the x-coordinate is taken. The [ScopeLift stealth-address-sdk](https://github.com/ScopeLift/stealth-address-sdk) hashes the shared secret exactly as [noble](https://github.com/paulmillr/noble-secp256k1) returns it. That is the 33-byte compressed point if noble's default applies, which I did not confirm from the library source.

Recomputing the vector in `test/stealth-point.test.ts` independently gives:

| Shared-secret encoding | View tag | Stealth address |
| --- | --- | --- |
| 65 bytes uncompressed (w3pk) | `0xe6` | `0xe313…b82c` (matches the test) |
| 33 bytes compressed | `0x13` | `0xb1a9…731d` |

A payment sent to a w3pk meta-address by a ScopeLift-based sender lands on an address that `scanAnnouncements()` never reports and `signTransaction()` refuses to sign for. The funds remain recoverable from the mnemonic with custom tooling, but not through w3pk. The only test vector is self-generated.

**Fix:** pick the encoding used by the ecosystem you want to interoperate with, add cross-implementation vectors, and document the choice. Changing it moves existing stealth addresses, so scanning needs to try both encodings during a transition.

### M-3. Gas values returned by the bundler are signed without bounds

**Where:** [`src/core/sdk.ts`](https://github.com/w3hc/w3pk/blob/v0.12.3/src/core/sdk.ts) lines 1411-1425.

In `PRIMARY` mode, `preVerificationGas`, `verificationGasLimit` and `callGasLimit` come from `eth_estimateUserOperationGas`, and the fee fields from the RPC endpoint. They go into the UserOperation that the passkey signs with no upper bound. `preVerificationGas` is paid in full to the bundler's beneficiary whatever the execution costs.

A compromised or dishonest bundler endpoint can therefore set a value that transfers most of the account's balance, or of a paymaster's sponsorship, to itself in a single operation. The security document states that a bundler can delay or drop a UserOperation but not alter it. That holds after signing, but the bundler shapes the operation before signing.

**Fix:** cap each gas field and the total prefund (`sum of gas limits × maxFeePerGas`), with a configurable ceiling, and include the maximum fee in what `onBeforeSign` receives.

### M-4. The NFT ownership circuit reveals the holder and binds nothing to the prover

**Where:** [`nft.circom`](https://github.com/w3hc/w3pk/blob/v0.12.3/src/zk/circuits/nft.circom), [`src/zk/proof-generator.ts`](https://github.com/w3hc/w3pk/blob/v0.12.3/src/zk/proof-generator.ts) lines 132-141.

- The public nullifier is `Poseidon(Poseidon(ownerAddress), contractAddress, root)`. The contract and root are public and the holder list is the tree itself, so hashing each holder identifies the prover's address. The stated goal of hiding the address is not met.
- The only private input is the hashed address. Nothing proves control of that address, so anyone can generate a valid proof for any holder.
- The balance check constrains `1 >= minBalance`, which says nothing about a balance.

**Fix:** derive the leaf from a secret the prover holds (as Semaphore does with identity commitments), compute the nullifier from that secret and an external scope, and remove or properly implement the balance claim.

## Low severity

**L-1. `sendTransaction()` documents an `onBeforeSign` option that does not exist.** The JSDoc at `sdk.ts` line 1188 lists `options.onBeforeSign`, but `SendTransactionOptions` has no such field and the method never calls a hook. An integrator who passes it gets no confirmation step and no error. Implement it or remove the line.

**L-2. The mnemonic outlives the session.** `currentWallet.mnemonic` is set by `register()`, `importMnemonic()` and the restore paths, and is cleared only by `logout()`. It stays in memory after the session expires and with `sessionDuration: 0`, which the documentation describes as keeping nothing in memory. `STRICT` calls also cache the mnemonic in the session, contrary to the "except in `STRICT`" wording. Drop `currentWallet.mnemonic` once the session is started, and skip caching for `STRICT`.

**L-3. Unauthenticated credential metadata decides the `PRIMARY` address.** `getAddress('PRIMARY')` derives the smart-account address from the `publicKey` stored in `localStorage`. One write to that record (a past [XSS](https://en.wikipedia.org/wiki/Cross-site_scripting), an extension) makes the app display an attacker-controlled deposit address after the attacker is gone. Recompute or verify the key against a fresh assertion before showing a funding address, or authenticate the record with a PRF-derived MAC.

**L-4. [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) request handling is permissive.** `from` and the `personal_sign` address are not compared with the account. The `input` alias for calldata is ignored, so such a request is sent as a plain value transfer. `gasPrice` is silently dropped. `wallet_switchEthereumChain` accepts any value, including one that parses to `NaN`, with no check that the chain is configured.

**L-5. The smart account trusts every origin in the RP ID scope.** The on-chain check verifies the challenge, flags and signature, not the origin or RP ID hash. Any page allowed to use the RP ID, including subdomains of the app's host and [related origins](https://www.w3.org/TR/webauthn-3/#sctn-related-origins), can obtain a valid UserOperation signature. The SDK's own strict origin check is not mirrored on chain. Host the wallet on a dedicated hostname with no user-controlled subdomains and say so in the integration guide.

**L-6. PRF outputs are used without checking the assertion in some paths.** `evaluatePrf()` requests user verification but does not check the UV flag or the signature. `login()` does both. The `register()` fallback and `derivePrfSecret()` rely on the browser alone. Apply the same checks everywhere a PRF output becomes key material.

**L-7. Shamir implementation details.** The x-coordinate inside `shareData` is not compared with `shareIndex` or rejected when zero. Only the first `threshold` shares are used, so one bad share among extras fails the recovery instead of trying another subset. Shares carry the wallet address in clear, which the v4 backup format deliberately avoids. Table lookups are not constant-time, as the June audit already noted.

**L-8. Remaining circuit weaknesses.** `GreaterEqThan(252)` and `LessEqThan(252)` are applied to inputs with no range check, outside circomlib's stated precondition. The membership nullifier is `Poseidon(leaf)` with no scope, so all proofs by one member are linkable, and guessable leaves can be brute-forced. `scripts/compile-circuits.js` produces a `.zkey` with no phase-2 contribution, which must never reach production.

**L-9. Release and supply chain.** The `v0.12.3` tag is lightweight and unsigned. Publishing is a manual `pnpm publish` without [npm provenance](https://docs.npmjs.com/generating-provenance-statements). [GitHub Actions](https://docs.github.com/en/actions) are pinned by tag rather than commit hash, and CI runs no dependency audit. `verifyBuildHash()` fetches the build from [unpkg](https://unpkg.com/), so a page checking itself proves little, which the documentation acknowledges.

## Informational

- **I-1.** The wallet record and persistent-session blob are encrypted without additional authenticated data. Binding `credentialId`, address and expiry, as BackupFile v4 does, would make the records self-authenticating.
- **I-2.** `config.storage` is accepted but never used, and `debug` is read from `DEFAULT_CONFIG` instead of the instance.
- **I-3.** Credential records are keyed by a hash of the credential ID, while the index next to them lists the raw IDs. The hashing adds nothing.
- **I-4.** `generateSiweNonce()` has a small modulo bias (256 mod 62), harmless at 11 characters. The [SIWE](https://eips.ethereum.org/EIPS/eip-4361) parser is lenient about field order.
- **I-5.** With `userVerification: 'preferred'`, a login that is not user-verified ends in `WalletMissingError`, which misdescribes the cause.
- **I-6.** `supportsEIP7702()` posts to public endpoints from [chainid.network](https://chainid.network/), exposing the user's IP address to them. Its static chain list dates from December 2024.
- **I-7.** `W3pkAccount` inherits OpenZeppelin draft contracts and is deployed on Sepolia only. A bug cannot be patched in place, by design.
- **I-8.** Typed data that includes unused types passes the digest step and then fails inside ethers. This affects availability only.

## Documented and accepted risks

These are real exposures that the project states openly. They are not new findings, but any integrator should read them as part of the audit.

- **The host app is trusted in every mode.** The SDK runs in the page. Any script on the origin can use a live session, and TypeScript `private` fields are reachable at runtime, so the session's mnemonic is readable by same-origin code. Origin-specific derivation does not protect a mnemonic restored on a hostile site.
- **Blind signing.** The passkey prompt shows the site, never the transaction.
- **Silent "Remember Me".** With `requireReauth: false` the session key sits next to the ciphertext, and anyone who reads `localStorage` gets the mnemonic.
- **Stealth keys are shared across origins** when the same mnemonic is used on several sites ([issue #147](https://github.com/w3hc/w3pk/issues/147)).
- **`YOLO` mode** hands private keys to the app.
- **`PRIMARY` accounts have one owner key** and no built-in recovery: a lost, unsynced passkey loses the account.
- **Guardian share checksums are unkeyed**, and ML-KEM payloads carry no sender authentication.

## What is done well

- At-rest keys come only from a user-verified PRF evaluation, with separate HKDF labels for the wallet and session keys and no weaker fallback.
- BackupFile v4 authenticates every metadata field as AES-GCM additional data, bounds the Argon2id parameters it accepts and stores no address.
- Origin paths are fully hardened and use 124 bits of the origin hash.
- The hybrid KEM follows [RFC 9180](https://www.rfc-editor.org/rfc/rfc9180) with a combiner that binds ciphertexts and public keys, and zeroizes intermediate secrets.
- WebAuthn responses are checked for type, challenge, exact origin, RP ID hash and signature counter. The CBOR and DER parsers are strict.
- Raw-hash signing and [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) authorizations always require a fresh assertion and are refused in `PRIMARY`.
- SIWE messages for another domain are refused, including when disguised as `personal_sign` bytes.
- The account contract is small, non-upgradeable, initialized atomically by the factory and salted with the owner key.

## Recommended order of work

1. Make `register()` fail, or signal clearly, when the wallet cannot be stored (H-1).
2. Constrain `pathIndices` and redesign the NFT circuit, or remove both circuits until fixed (H-2, M-4).
3. Bind `onBeforeSign` digests to the signed payload and implement or remove the `sendTransaction()` option (M-1, L-1).
4. Cap bundler-supplied gas (M-3).
5. Decide the stealth shared-secret encoding and add external vectors (M-2).
6. Clear `currentWallet.mnemonic`, harden the EIP-1193 inputs and authenticate credential metadata (L-2 to L-4).
7. Sign tags, publish with provenance and pin Actions by hash (L-9).
8. Run the test suite, `pnpm audit`, Foundry tests and Slither against this tag, since none of them were run for this review.

## Further reading

- [w3pk security architecture](https://github.com/w3hc/w3pk/blob/v0.12.3/docs/SECURITY.md)
- [Previous audit, June 9, 2026](https://github.com/w3hc/w3pk/blob/v0.12.3/docs/JUNE_9_2026_AUDIT.md)
- [Web Authentication Level 3](https://www.w3.org/TR/webauthn-3/)
- [ERC-5564: Stealth Addresses](https://eips.ethereum.org/EIPS/eip-5564)
- [ERC-4337: Account Abstraction](https://eips.ethereum.org/EIPS/eip-4337)
- [EIP-712: Typed structured data hashing and signing](https://eips.ethereum.org/EIPS/eip-712)
- [EIP-7951: Precompile for secp256r1 curve support](https://eips.ethereum.org/EIPS/eip-7951)
- [snarkjs](https://github.com/iden3/snarkjs)
