Passwordless Web3 authentication SDK with encrypted wallets and privacy features.
Live demo: w3pk.w3hc.org
npm install w3pk ethers
# or, with viem (ethers v6 is a required peer dependency either way)
npm install w3pk ethers viemimport { createWeb3Passkey } from 'w3pk'
const w3pk = createWeb3Passkey({
// RPC endpoints you trust, per chain: sendTransaction() throws for a chain without one
rpcUrls: { 1: ['https://eth.example.com'], 8453: ['https://base.example.com'] }
})
// Register new user (generates wallet, stores with WebAuthn)
const { address, username } = await w3pk.register({ username: 'alice' })
// Login for subsequent sessions
await w3pk.login()
// Sign messages (EIP-191, SIWE, EIP-712, rawHash)
const { signature } = await w3pk.signMessage('Hello World')
// Send transactions on-chain
const tx = await w3pk.sendTransaction({ to: '0x...', value: 1n * 10n**18n, chainId: 1 })
// EIP-1193 provider (ethers, viem, wagmi, RainbowKit)
const eip1193 = w3pk.getEIP1193Provider({ chainId: 1 })
// Get RPC endpoints
const endpoints = await w3pk.getEndpoints(1)- Passwordless authentication (WebAuthn/FIDO2)
- Origin-specific key isolation with tag-based access control
- Session management (in-memory + optional persistent)
- HD wallet generation (BIP39/BIP44)
- Multi-address derivation with security modes (STANDARD/STRICT/YOLO)
- Multiple signing methods (EIP-191, SIWE/EIP-4361, EIP-712, rawHash)
- On-chain transaction sending through the RPC endpoints you set in
rpcUrls(sendTransaction) - EIP-1193 provider for ethers, viem, wagmi, RainbowKit (
getEIP1193Provider) - ERC-5564 stealth addresses (opt-in)
- ZK primitives (zero-knowledge proof generation and verification, Baby Jubjub keys, exponential ElGamal and circuit artifact verification against onchain pins in
w3pk/zk, passkey-bound Semaphore identities withgetSemaphoreIdentity(), and proofs over a passkey-bound secret the app never sees withzkProve()) - Hybrid post-quantum encryption (HPKE over ML-KEM-1024 + X25519)
- Chainlist support (every network on chainid.network, fetched live)
- EIP-7702 network detection (329 known networks, RPC probing for the others)
- EIP-7951 PRIMARY mode: a P-256 passkey smart account (ERC-4337, EIP-1271) that signs and sends without trusting the host app (trust model)
- Build verification (IPFS CID hashing + DAO-maintained onchain registry)
- Three-layer backup & recovery (passkey sync, encrypted backups, social recovery)
w3pk runs inside the host app's page, as code the host serves. What the host can do depends on the mode.
PRIMARY: the user doesn't need to trust the host app.
- The passkey's P-256 key never leaves the authenticator (or the passkey provider that syncs it). The account is a
W3pkAccountsmart account that verifies the passkey's WebAuthn assertions onchain. - Nothing in the page can sign without a user-verified prompt: no mnemonic, no session key, no exportable private key. A host that ships a modified SDK still can't extract the key, or sign once the user has left.
- The passkey is bound to the host's domain (its RP ID), so another site can't ask for it.
A malicious host app can still attempt the following in PRIMARY mode:
- Blind signing. The passkey prompt shows the site and the account, never what is being signed. The host can show "Sign in" and get a UserOperation or a message signed instead. That includes a transfer, a token approval, an EIP-712 permit or Permit2 signature, or an ERC-7579
installModulethat gives it a lasting validator, executor or hook on the account. - Prompt fatigue. It can ask for prompt after prompt until the user approves one without thinking.
- Misleading display. It controls the UI and chooses the RPC, so it can show false balances, prices, recipients or transaction results.
- Censorship. It chooses the bundler and paymaster. It can delay, drop or refuse to send a UserOperation, but can't change one after it is signed.
- Privacy. It learns the account's address. Every tag's account shares the same owner key, so they're linkable onchain.
So in PRIMARY the user's protection is the prompt: approve it only for an action you started. Spending limits and allowlists can be enforced onchain by an ERC-7579 hook, which sees every execution.
STRICT, STANDARD and YOLO: the user trusts the host app.
- The SDK decrypts the PRF-encrypted mnemonic in the page to derive secp256k1 keys. Code running on the host's origin can therefore reach those keys: the host itself, a malicious dependency, a compromised CDN or an XSS.
- In
STANDARDandYOLO, that code can sign silently while a session is live. InYOLO, the app gets the private key itself.STRICTkeeps no session and asks for the passkey on every call, but the key still sits in the page's memory while it signs. - These modes are for apps the user already trusts. Build verification lets an honest host prove which w3pk build it serves, but it doesn't protect anyone from a dishonest host.
See SECURITY.md for the full model.
// List all wallets on device
const wallets = await w3pk.listExistingCredentials()
// Register or login
if (wallets.length > 0) {
await w3pk.login()
} else {
const { address, username } = await w3pk.register({ username: 'alice' })
}
// Logout
await w3pk.logout()w3pk supports multiple security modes for deriving wallets with different privacy and security trade-offs:
// PRIMARY mode - WebAuthn P-256 passkey (EIP-7951)
// Uses hardware-backed passkey directly, no seed phrase involved
const primaryWallet = await w3pk.deriveWallet('PRIMARY')
// Returns: { address, publicKey, origin, mode: 'PRIMARY', tag: 'MAIN' }
// STANDARD mode - Default balanced security (recommended)
// Returns address only, private key stays in SDK for signing
const mainWallet = await w3pk.deriveWallet('STANDARD')
// Returns: { address, path, origin, mode: 'STANDARD', tag: 'MAIN' }
// YOLO mode - Private key exposed to app
// Use only when app needs direct key access (advanced use cases)
const gamingWallet = await w3pk.deriveWallet('YOLO', 'GAMING')
// Returns: { address, privateKey, path, origin, mode: 'YOLO', tag: 'GAMING' }
// STRICT mode - Maximum security, re-auth required every time
// Requires biometric/PIN for each call - impractical for most apps
const strictWallet = await w3pk.deriveWallet('STRICT', 'SECURE')
// Returns: { address, path, origin, mode: 'STRICT', tag: 'SECURE' }
// Different tags generate different addresses
console.log(mainWallet.address !== gamingWallet.address) // trueSecurity: Master mnemonic is never exposed. No method returns it, and restores (restoreFromBackupFile(), syncWalletWithPasskey(), recoverFromGuardians()) return only the address.
// EIP-191 (default)
// Returns: { signature, address, mode, tag, origin }
const { signature } = await w3pk.signMessage('Hello World')
// SIWE (Sign-In with Ethereum)
import { createSiweMessage } from 'w3pk'
const siweMessage = createSiweMessage({ ... })
const siweSig = await w3pk.signMessage(siweMessage, {
signingMethod: 'SIWE'
})
// EIP-712 (typed data)
const eip712Sig = await w3pk.signMessage(JSON.stringify(typedData), {
signingMethod: 'EIP712',
eip712Domain,
eip712Types,
eip712PrimaryType: 'Transfer'
})
// Raw hash: a 32-byte hex hash (64 hex characters, optional 0x), always prompts for the passkey
const rawSig = await w3pk.signMessage(hash, {
signingMethod: 'rawHash'
})
// Force authentication for sensitive operations
const sensitiveSig = await w3pk.signMessage('Transfer $1000', {
requireAuth: true
})// Check which address will be used before sending
const from = await w3pk.getAddress('STANDARD', 'MAIN')
console.log('sending from:', from)
// Send ETH — defaults to STANDARD mode, MAIN tag, current origin
// sender = getOriginSpecificAddress(mnemonic, window.location.origin, 'STANDARD', 'MAIN')
const result = await w3pk.sendTransaction({
to: '0xRecipient...',
value: 1n * 10n**18n, // 1 ETH in wei
chainId: 1
})
console.log('tx hash:', result.hash)
console.log('from:', result.from) // same address as `from` above
console.log('mode:', result.mode) // 'STANDARD'
// Send contract call with a per-call RPC endpoint and STRICT auth
const callResult = await w3pk.sendTransaction(
{ to: '0xContract...', data: '0xabcd...', chainId: 10 },
{ mode: 'STRICT', rpcUrl: 'https://mainnet.optimism.io' }
)
// YOLO mode — app-specific isolated address
const yoloTx = await w3pk.sendTransaction(
{ to: '0x...', value: 5n * 10n**17n, chainId: 8453 },
{ mode: 'YOLO', tag: 'GAMING' }
)sendTransaction() uses options.rpcUrl, then the first rpcUrls endpoint for the chain, and throws a WalletError naming rpcUrls if neither is set, before any passkey prompt. It never sends through public chainid.network endpoints: the RPC supplies the nonce, gas and fees, so use endpoints you trust.
Mode behaviour:
| Mode | Auth on send | Gas source |
|---|---|---|
| STANDARD | Session (auto) | Sender address |
| STRICT | Always (biometric) | Sender address |
| YOLO | Session (auto) | Sender address |
| PRIMARY | Passkey prompt per call | Smart account, or an ERC-7677 paymaster |
In PRIMARY mode the passkey's smart account sends an ERC-4337 UserOperation through the bundler set in bundlerUrls, deploys itself on its first transaction on a chain, and executes an array of transactions atomically. For now the account's factory is deployed only on Sepolia (11155111), so PRIMARY accounts work only there; the other chains will follow at the same addresses (deploying the contracts). The returned hash is the including transaction's:
const w3pk = createWeb3Passkey({
rpcUrls: { 11155111: ['https://sepolia.example.com'] },
bundlerUrls: { 11155111: 'https://bundler.example.com/sepolia' <
8
span cla
10BC8
ss="pl-kos">}, // EntryPoint v0.9
paymasterUrl: 'https://paymaster.example.com', // optional: sponsors the gas
})
const { hash } = await w3pk.sendTransaction(
[{ to: token, data: approveCalldata, chainId: 11155111 }, { to: router, data: swapCalldata, chainId: 11155111 }],
{ mode: 'PRIMARY' }
)Use w3pk with EIP-1193 consumers — viem, wagmi, RainbowKit, an ethers signer — without exposing private keys. The provider serves accounts, signing and sending only; read the chain through your own RPC.
const eip1193 = w3pk.getEIP1193Provider({ chainId: 1 })ethers v6 (signing only: signer.sendTransaction() reads the chain through the provider, which doesn't forward reads)
import { BrowserProvider } from 'ethers'
const signer = await new BrowserProvider(eip1193).getSigner()
const sig = await signer.signMessage('Hello World')viem
import { createWalletClient, custom, parseEther } from 'viem'
import { mainnet } from 'viem/chains'
const client = createWalletClient({ chain: mainnet, transport: custom(eip1193) })
const [address] = await client.getAddresses()
const hash = await client.sendTransaction({ to: '0x...', value: parseEther('1') })Supported JSON-RPC methods:
| Method | Action |
|---|---|
eth_accounts / eth_requestAccounts |
Returns the address for the provider's mode and tag options (STANDARD and MAIN by default) |
eth_chainId |
Returns the active chain as hex (the chainId option, 1 by default) |
eth_sendTransaction |
Delegates to sendTransaction() |
personal_sign / eth_sign |
EIP-191 message signing (eth_sign rejects with code 4200 in PRIMARY) |
eth_signTypedData_v4 |
EIP-712 typed data signing, bound to the active chain |
wallet_switchEthereumChain |
Updates active chainId, emits chainChanged |
// In-memory sessions (default, 1 hour)
const w3pk = createWeb3Passkey({
sessionDuration: 2 // 2 hours
})
// Persistent sessions (survives page refresh)
const w3pkPersistent = createWeb3Passkey({
sessionDuration: 1,
persistentSession: {
enabled: true,
duration: 168, // 7 days (in hours)
requireReauth: true // Prompt on refresh
}
})
// Auto-restore mode (silent restore)
const w3pkAutoRestore = createWeb3Passkey({
persistentSession: {
enabled: true,
duration: 30 * 24,
requireReauth: false
}
})
// Session status
w3pk.hasActiveSession()
w3pk.getSessionRemainingTime()
w3pk.extendSession()
await w3pk.clearSession()Note: STRICT mode never allows persistent sessions.
PRF-keyed storage: the wallet stored on the device and persistent sessions are encrypted under keys derived from the WebAuthn PRF extension (an authenticator-held secret, released only during a biometric/PIN-verified assertion), so storage alone can't decrypt them. The exception is silent restore (requireReauth: false): the PRF-derived key is stored in localStorage next to the encrypted session, so any script on the origin can decrypt it until it expires. Authenticators without PRF support keep no wallet on the device and get in-memory sessions only: the wallet comes back from a backup on the next visit. There is no weaker at-rest fallback. See Security Architecture for details.
// Get RPC endpoints for any chain: your `rpcUrls` first, then Chainlist's public endpoints
const endpoints = await w3pk.getEndpoints(1) // Ethereum
const optimismRpc = await w3pk.getEndpoints(10) // Optimism
const arbitrumRpc = await w3pk.getEndpoints(42161) // Arbitrum
// Use with ethers.js
import { ethers } from 'ethers'
const provider = new ethers.JsonRpcProvider(endpoints[0])
const blockNumber = await provider.getBlockNumber()// Check network support
const supported = await w3pk.supportsEIP7702(1)
// Configure RPC testing
await w3pk.supportsEIP7702(999, {
maxEndpoints: 5,
timeout: 5000
})
// Sign authorization for gasless transactions
const authorization = await w3pk.signAuthorization({
contractAddress: '0x...',
chainId: 1,
nonce: 0n
})
// Returns: { chainId, address, nonce, yParity, r, s }// Get PRIMARY address (the passkey's P-256 smart account, one per tag)
const primaryAddr = await w3pk.getAddress('PRIMARY')
// Sign with the passkey, as the smart account (EIP-1271)
const { signature } = await w3pk.signMessage("Hello World", { mode: 'PRIMARY', chainId: 11155111 })The PRIMARY address is the counterfactual address of the passkey's P-256 smart account, computed offchain with no RPC call. Each tag has its own account; accounts of the same passkey are linkable onchain through their shared owner.
signMessage() in PRIMARY mode signs EIP-191, SIWE and EIP-712 messages with the passkey as ERC-7739 signatures, bound to the account and the chain. They verify through the account's isValidSignature (EIP-1271), or, while it isn't deployed, as ERC-6492 signatures: viem's verifyMessage and verifyTypedData handle both. rawHash throws in PRIMARY mode. sendTransaction() sends as the account through an ERC-4337 bundler: see Sending Transactions.
const w3pk = createWeb3Passkey({
stealthAddresses: {}
})
// Get stealth meta-address
const metaAddress = await w3pk.stealth?.getStealthMetaAddress()
// Pay someone: generate a one-time address from their meta-address (no passkey needed)
import { generateStealthAddress } from 'w3pk'
const { stealthAddress, ephemeralPubKey, viewTag } = generateStealthAddress(theirMetaAddress)
// Generate a one-time address for your own meta-address
const announcement = await w3pk.stealth?.generateStealthAddress()
// Check if announcement is for you
const result = await w3pk.stealth?.parseAnnouncement({
stealthAddress: announcement.stealthAddress,
ephemeralPublicKey: announcement.ephemeralPublicKey,
viewTag: announcement.viewTag
})
if (result.isForUser) {
console.log('Payment received at:', result.stealthAddress)
}
// Scan multiple announcements
const myPayments = await w3pk.stealth?.scanAnnouncements(announcements)
// Sign a transaction from a stealth address (broadcast it with your own RPC)
const signedTx = await w3pk.stealth?.signTransaction(announcement, {
to: '0x...',
value: 1000000000000000n,
chainId: 11155111,
nonce: 0,
gasLimit: 21000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n
})Stealth keys are shared across origins, and w3pk never returns the spending private key or a stealth private key: it signs from stealth addresses itself. w3pk.stealth?.getKeys() does return the viewing private key, which lets an app see every payment to the user but not spend them. Any app that enables stealth addresses can sign any transaction from them, and the passkey prompt doesn't show the transaction (#147).
// Hybrid ML-KEM-1024 + X25519 (HPKE, payload v3)
const pubKey = await w3pk.deriveMLKemPublicKey()
const encrypted = await w3pk.mlkemEncrypt('secret', [pubKey])
const plaintext = await w3pk.mlkemDecrypt(encrypted)Payloads have no sender authentication: any recipient can seal new content for the others. Sign the content inside the payload with signMessage() to prove who wrote it (POST_QUANTUM.md).
import { isStrongPassword } from 'w3pk'
// Validate password strength
const password = 'violet lantern under seven bridges'
if (!isStrongPassword(password)) {
throw new Error('Password must be 15+ characters and not a common password')
}
// Get backup status
const status = await w3pk.getBackupStatus()
console.log('Security Score:', status.securityScore.total) // 0-70
console.log('Next backup:', status.recommendedMode) // 'password', then 'prf'
// Create encrypted backup file (BackupFile v4: Argon2id, metadata authenticated, no address in the file)
const { blob, filename } = await w3pk.createBackupFile(password)
// Setup social recovery (M-of-N guardians) - guardians store backup file fragments
const { guardianShares } = await w3pk.setupSocialRecovery(
[
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob' },
{ name: 'Charlie' }
],
2, // threshold
password // encrypts the backup the shares rebuild
)
// Generate guardian invite (share file, printable document, optional QR code)
const { downloadBlob, filename, shareDocument } = await w3pk.generateGuardianInvite(guardianShares[0])
// Recover from guardian share files - rebuilds and decrypts the backup
// (logged out: the wallet stays in memory for register(); logged in: it replaces the stored one)
const { ethereumAddress } = await w3pk.recoverFromGuardians([share1, share2], password)
// Restore from backup file (another wallet than the logged-in one needs
// the user's confirmation: { allowAddressChange: true })
await w3pk.restoreFromBackupFile(encryptedData, password)
// Simulate recovery scenarios
import { RecoverySimulator } from 'w3pk'
const result = await new RecoverySimulator().simulateScenario(
{ type: 'lost-device', description: 'Device lost with iCloud Keychain enabled' },
await w3pk.getBackupStatus()
)import { getCurrentBuildHash, getPackageVersion } from 'w3pk'
import { ethers } from 'ethers'
// Installed w3pk version
const installedVersion = getPackageVersion()
// IPFS hash of the build published on unpkg for that version
// (not the code actually running: hash the files you ship at build time to check those)
const hash = await getCurrentBuildHash()
console.log('Installed version:', installedVersion)
console.log('Published build hash:', hash)
// Verify against DAO-maintained onchain registry (OP Mainnet)
const REGISTRY = '0xAF48C2DB335eD5da14A2C36a59Bc34407C63e01a'
const ABI = ['function getCidByVersion(string version) view returns (string)']
const provider = new ethers.JsonRpcProvider('https://mainnet.optimism.io')
const registry = new ethers.Contract(REGISTRY, ABI, provider)
// Query registry for the specific installed version (note: "v" prefix required)
const onchainCid = await registry.getCidByVersion(`v${installedVersion}`)
const isValid = hash === onchainCid
console.log('Onchain CID:', onchainCid)
console.log('Verified:', isValid ? '✅' : '❌')W3PK maintains a DAO-controlled onchain registry of verified build hashes on OP Mainnet:
- Registry Contract:
0xAF48C2DB335eD5da14A2C36a59Bc34407C63e01a - Network: OP Mainnet (Chain ID: 10)
- Purpose: Immutable source of truth for official W3PK releases
Host applications should verify their installed W3PK build against this registry. See Build Verification for implementation details.
- Quick Start Guide
- Integration Guidelines
- API Reference
- Architecture
- Build Verification
- EIP-7702
- EIP-7951
- Deploying the contracts -
PRIMARYaccount contracts on a new network - Security Architecture
- Post-Quantum Cryptography - Quantum-safe migration roadmap
- Recovery & Backup System
- Portability Guide
- ZK Proofs
- Browser Compatibility
See CONTRIBUTING.md
GPL-3.0
Questions or feedback? Get in touch with Julien: https://julienberanger.com/contact