Building a Passwordless EVM Wallet

10 min read

Building a Passwordless EVM Wallet

When you're building modern dApps, user onboarding and ongoing interaction are critical. Let's be honest, the traditional Web3 experience – juggling seed phrases, managing gas tokens – can scare off even enthusiastic users. That's where passwordless wallets come in, and you're about to learn how to build one that's both secure and refreshingly simple for your users.

This guide, inspired by Openfort's slick demonstration of EIP-7702 with ERC-4337, will walk you through creating a wallet experience where users interact with the blockchain using familiar WebAuthn credentials (like Passkeys). Think of it: no more "write this down and keep it safe" lectures! But before you jump into the code and start wiring things up, it’s essential to grasp the core components. Getting these concepts clear from the outset will make your development journey smoother and your final product much more robust.

If you want to check the code directly here is the repo.

Key Concepts & Glossary: The Tech You Need to Know

Before you start building, let's break down the essential tech powering this passwordless revolution. Understanding these terms isn't just academic; it's about knowing the tools that will let you deliver a top-tier user experience.

  • Externally Owned Account (EOA): This is your starting block – the standard Ethereum account controlled by a private key. While it's the most common account type, we're about to give it some serious upgrades.
  • EIP-7702: Think of this as the EOA "empowerment" proposal. It allows an EOA to temporarily grant specific permissions to a smart contract. This is key because it lets your EOA gain smart contract-like abilities without the immediate overhead of deploying a full smart contract wallet for every user.
  • Delegation Contract: This is the trusted smart contract your EOA (via EIP-7702) authorizes to act on its behalf. It’s the intermediary that will execute actions based on the EOA’s approved instructions, enabling more complex operations.
  • ERC-4337 (Account Abstraction): This is the standard that brings true "Account Abstraction" to Ethereum without needing deep protocol changes. For you, this means unlocking powerful features like gas sponsorship and improved transaction flows, making your dApp far more accessible.
  • UserOperation (UserOp): With ERC-4337, users (or their accounts) don't just send plain old transactions. Instead, they submit UserOperation objects to a special, alternative mempool. This is how we tap into the advanced features of Account Abstraction.
  • Bundler: These are the workhorses of the ERC-4337 ecosystem. Bundlers pick up UserOps, package them efficiently into a single on-chain transaction, and get them executed. They handle the complexity so your users don't have to.
  • WebAuthn / Passkeys: This is the heart of the "passwordless" experience. A web standard that lets users authenticate using biometrics (Face ID, Touch ID) or physical security keys. For your users, this means maximum security with minimal friction – a huge win.
  • RIP-7212 P256 Precompile: For WebAuthn to be useful on-chain, signatures need to be verifiable. This precompile makes verifying the P256 signatures (common in WebAuthn) efficient and standard on Ethereum, securely linking Passkeys to blockchain accounts.
  • Paymaster: An ERC-4337 smart contract that can pay gas fees on behalf of your users. This enables Sponsored Transactions, a game-changer for onboarding.
  • Session Keys: To avoid "signature fatigue" (users constantly having to approve transactions), you can implement session keys. These are temporary, limited-permission keys that can be authorized for a certain number of actions or time, allowing for smoother, uninterrupted interactions within your dApp.
  • Batch Transactions: Another win for user experience, enabled by Account Abstraction. This allows multiple distinct operations (e.g., approve a token and then swap it) to be combined and executed as a single, atomic transaction.
  • IndexedDB: A browser-based database. In our context, it's a practical example of where you might securely store non-extractable session keys locally in the user's browser, keeping them handy but safe.

How It All Clicks: Your Passwordless Architecture Blueprint

So, how do these pieces – EOAs, EIP-7702, ERC-4337, WebAuthn – actually combine to deliver that seamless passwordless flow? It's not just about individual components; it's their smart integration:

Traditional wallets expose private keys to the application layer, creating multiple attack vectors. Traditional wallets expose private keys to the application layer, creating multiple attack vectors. Then, using EIP-7702, you'll empower this EOA by allowing it to designate a Delegation Contract. This contract becomes a trusted agent, ready to execute operations based on your EOA’s authority.

Next, you'll bring in WebAuthn (Passkeys) for authentication. Users will use their familiar device biometrics or security keys, and thanks to the RIP-7212 P256 Precompile, these credentials can securely control the EOA via the Delegation Contract.

The ERC-4337 standard then kicks things into high gear. User intentions become UserOperations, processed by Bundlers. This is your gateway to features that truly differentiate your dApp:

  • Sponsored Transactions (via Paymasters): Onboard users without them ever touching gas.
  • Session Keys: Let users perform multiple actions within a session without constant signature prompts.
  • Batch Transactions: Simplify complex multi-step processes into single clicks.

By thoughtfully combining these technologies, you’re not just building a wallet; you’re crafting a user experience that’s years ahead of the curve. You're setting up a secure, trustworthy, and incredibly intuitive way for users to engage with the decentralized web.

With these foundational concepts in place, you’re well-prepared to tackle the code and bring your passwordless wallet to life. Let's get building!

Setting Up WebAuthn Authentication

Creating Your First Passkey

The journey begins by creating a WebAuthn credential that will serve as the primary authentication method for your smart account. This credential is tied to your device and protected by biometric authentication or a PIN.


_17
import { Bytes, WebAuthnP256 } from "ox";
_17
_17
// Create a WebAuthn credential using the account address as the user ID
_17
const credential = await WebAuthnP256.createCredential({
_17
authenticatorSelection: {
_17
requireResidentKey: false,
_17
residentKey: "preferred", // Store credential on device when possible
_17
userVerification: "required", // Require biometric/PIN verification
_17
},
_17
user: {
_17
id: Bytes.from(account.address),
_17
name: `${account.address.slice(0, 6)}...${account.address.slice(-4)}`,
_17
},
_17
});
_17
_17
// Store the credential ID for future authentication
_17
const credentialId = credential.id;

This process prompts the user to authenticate (via Face ID, Touch ID, Windows Hello, etc.) and creates a unique key pair bound to their device. The private key remains in secure hardware, while the public key is returned for smart account initialization.

Initializing the Smart Account

With the WebAuthn credential created, we can now initialize a smart account that recognizes this passkey as its primary signer:


_30
// Prepare the initialization call data
_30
const callData = encodeFunctionData({
_30
abi: accountABI,
_30
functionName: "initialize",
_30
args: [
_30
{
_30
pubKey: {
_30
x: toHex(x), // The WebAuthn public key x coordinate
_30
y: toHex(y), // The WebAuthn public key y coordinate
_30
},
_30
eoaAddress: zeroAddress, // address(0)
_30
keyType: KEY_TYPE_WEBAUTHN, // WEBAUTHN key type identifier
_30
},
_30
spendTokenInfo, // Token spending configuration
_30
[ // Function selectors this key can call
_30
'0xa9059cbb', // transfer(address,uint256)
_30
'0x40c10f19' // mint(address,uint256)
_30
],
_30
messageHash, // Verification message hash
_30
signatureInit, // Initial signature for verification
_30
validUntil, // Key expiration timestamp
_30
nonce + 1n, // Account nonce
_30
],
_30
});
_30
_30
// Create the user operation with EIP-7702 authorization
_30
const userOperation = await smartAccountClient.prepareUserOperation({
_30
callData,
_30
authorization: signedAuthorization, // EIP-7702 authorization signature
_30
});

The EIP-7702 authorization allows an EOA to temporarily act as a smart contract, enabling the initialization process. Once this UserOperation is executed, your smart account is ready for WebAuthn-based transactions.

Executing Transactions with WebAuthn

Preparing the Transaction

Let's demonstrate by minting some ERC-20 tokens. The process involves preparing a UserOperation with a placeholder signature, then replacing it with the actual WebAuthn signature:


_20
// Encode the mint function call
_20
const data = encodeFunctionData({
_20
abi: erc20ABI,
_20
functionName: "mint",
_20
args: [
_20
smartAccountClient.account.address,
_20
parseEther("10"), // Mint 10 tokens
_20
],
_20
});
_20
_20
// Prepare UserOperation with stub signature
_20
const userOperation = await smartAccountClient.prepareUserOperation({
_20
calls: [
_20
{
_20
to: erc20Address,
_20
data,
_20
},
_20
],
_20
signature: webAuthnStubSignature, // Placeholder signature
_20
});

Signing with WebAuthn

Now we generate the actual signature using the WebAuthn credential:


_16
// Get the UserOperation hash that needs to be signed
_16
const userOperationHash = getUserOperationHash(userOperation);
_16
_16
// Sign with WebAuthn - this triggers biometric authentication
_16
const webauthnData = await WebAuthnP256.sign({
_16
challenge: userOperationHash,
_16
credentialId, // The credential we created earlier
_16
rpId: window.location.hostname, // Relying party identifier
_16
userVerification: "required", // Require user verification
_16
});
_16
_16
// Replace stub signature with the actual WebAuthn signature
_16
userOperation.signature = encodeWebAuthnSignature(webauthnData);
_16
_16
// Send to bundler for execution
_16
await bundlerClient.sendUserOperation(userOperation);

The user experiences a familiar biometric prompt (Face ID, fingerprint, etc.), and once authenticated, the transaction is signed and submitted.

Hint: The stub signature has to be different for P256 and WebAuthn. The actual webauthn signature is significantly different from the P256 signature, so the stub signature has to be different as well.

Implementing Session Keys for Seamless UX

While WebAuthn provides excellent security, requiring biometric authentication for every transaction can impact user experience. Session keys solve this by creating temporary, limited-privilege keys for routine operations.

Creating a Session Key

Session keys are also non-extractable P256 keys, but they're created using the WebCrypto API and stored locally:


_10
import { WebCryptoP256 } from "ox";
_10
_10
// Generate a new P256 key pair for the session
_10
const keyPair = await WebCryptoP256.createKeyPair();
_10
const publicKey = await WebCryptoP256.getPublicKey({
_10
publicKey: keyPair.publicKey
_10
});
_10
_10
// Store the key pair securely (e.g., in IndexedDB)
_10
await storeSessionKey(keyPair);

Registering the Session Key

The session key must be registered with the smart account, defining its permissions and validity:


_39
const callData = encodeFunctionData({
_39
abi: accountABI,
_39
functionName: 'registerSessionKey',
_39
args: [
_39
{
_39
pubKey: {
_39
x: toHex(publicKey.x), // The P256 public key x coordinate
_39
y: toHex(publicKey.y), // The P256 public key y coordinate
_39
},
_39
eoaAddress: zeroAddress, // address(0)
_39
keyType: KEY_TYPE_P256, // P256 key type identifier
_39
},
_39
validUntil, // Session expiration
_39
0, // Nonce
_39
limits, // Spending/call limits
_39
true, // Authorized flag
_39
erc20Address, // Contract this key can interact with
_39
spendTokenInfo, // Token spending configuration
_39
[ // Function selectors this key can call
_39
'0xa9059cbb', // transfer(address,uint256)
_39
'0x40c10f19' // mint(address,uint256)
_39
],
_39
ethLimit // ETH spending limit
_39
]
_39
});
_39
_39
// This registration requires WebAuthn approval
_39
const userOperation = await smartAccountClient.prepareUserOperation({
_39
callData,
_39
signature: webAuthnStubSignature,
_39
});
_39
_39
// Sign with WebAuthn (one-time approval for session key)
_39
const webauthnData = await WebAuthnP256.sign({
_39
challenge: getUserOperationHash(userOperation),
_39
credentialId,
_39
rpId: window.location.hostname,
_39
userVerification: "required",
_39
});

Hint: Webauthn rpId is crucial. It binds credentials to a specific domain for strong phishing protection. If you don't provide it, the browser will throw an error.

Using Session Keys for Transactions

Once registered, session keys can sign transactions without requiring biometric authentication:


_26
// Prepare the transaction as before
_26
const userOperation = await smartAccountClient.prepareUserOperation({
_26
calls: [
_26
{
_26
to: erc20Address,
_26
data: encodeFunctionData({
_26
abi: erc20ABI,
_26
functionName: "mint",
_26
args: [smartAccountClient.account.address, parseEther("10")],
_26
}),
_26
},
_26
],
_26
signature: P256StubSignature, // Different stub for P256 keys
_26
});
_26
_26
// Sign with the session key (no user interaction required)
_26
const { r, s } = await WebCryptoP256.sign({
_26
privateKey: sessionKey.privateKey,
_26
payload: getUserOperationHash(userOperation),
_26
});
_26
_26
// Format and attach the signature
_26
userOperation.signature = encodeP256Signature({ r, s });
_26
_26
// Submit the transaction
_26
await bundlerClient.sendUserOperation(userOperation);

Conclusion

WebAuthn with P256 signatures represents a significant leap forward in Web3 user experience and security. By eliminating private key exposure and leveraging familiar authentication methods, we can build smart accounts that are both more secure and more user-friendly than traditional wallets.

Share this article