How to Add Support for Passkeys Authentication
Passkeys secure on-chain smart accounts using fingerprint, face recognition, or device PIN codes. Users no longer need to manage complex private keys. Instead, they access their digital wallets using passkey-enabled devices synced via Apple's iCloud Keychain or cross-platform password managers like Proton Pass and Bitwarden.
Passkeys replace traditional seed phrase backups in Ethereum wallets. Unlike the secp256k1 curve used for Externally Owned Accounts (EOAs), Passkeys use the secp256r1 curve. These keys leverage device secure enclave cryptography, built on the WebAuthn standard using public-key cryptography developed by the FIDO Alliance (Apple, Google, Microsoft, and others).
Safe Passkeys contracts are developed by the Safe Protocol Team. The contracts and audits are available in the Safe-Modules repository. Deployment addresses can be found on our contract deployment page.
What You'll Build
By the end of this guide, you'll have:
- Created WebAuthn passkey credentials for biometric authentication
- Initialized a Safe Smart Account with a passkey signer
- Signed and submitted a UserOperation using passkeys
Prerequisites
Before starting, make sure you have:
- Node.js 18+ and npm or yarn
- Basic TypeScript knowledge
- Familiarity with Safe Smart Accounts and UserOperations. See the Getting Started guide if you're new
This guide demonstrates a 1/1 Safe with a single passkey signer. For production use, consider adding a second access method since passkeys can be tied to specific domains or device ecosystems. A 1/2 multisig setup or the recovery module gives users a backup path if they switch devices or lose access to their passkey
Quick start
Demo
These examples showcase Safe Smart Account deployments using ERC-4337 and Passkeys:
- React Demo: Full browser-based passkeys flow with account creation and transaction signing.
- React Native Demo: Mobile passkeys integration using
react-native-passkeyandcbor-web. - Node.js Demo: Client side example with simulated passkeys for testing and CI environments.
Create a Passkey-Authenticated Smart Account
Step 1: Install Dependencies
Install abstractionkit for Safe account tooling and ox for WebAuthn interactions.
- npm
- yarn
npm i abstractionkit ox
yarn add abstractionkit ox
The ox library provides a high-level abstraction over the browser's WebAuthn API. The ox/WebAuthnP256 module handles credential creation and signing with P-256 keys, removing the need to work with raw WebAuthn responses.
Step 2: Create WebAuthn Credentials
Call createCredential from ox/WebAuthnP256 to trigger the browser's passkey prompt. This returns a credential object containing the public key and credential ID.
import { createCredential } from 'ox/WebAuthnP256'
const passkeyCredential = await createCredential({
name: 'Safe Wallet',
// Random challenge to prevent replay attacks
challenge: crypto.getRandomValues(new Uint8Array(32)),
rp: {
// Ties the credential to the current domain
id: window.location.hostname,
name: 'Safe Wallet'
},
authenticatorSelection: {
// Use device biometrics (Touch ID, Face ID, Windows Hello)
authenticatorAttachment: 'platform',
residentKey: 'required',
userVerification: 'required',
},
timeout: 60000,
attestation: 'none',
})
Step 3: Extract Public Key
The createCredential function returns the public key coordinates directly. Wrap them in a WebauthPublicKey object for use with abstractionkit.
import { WebauthPublicKey } from "abstractionkit";
const webauthPublicKey: WebauthPublicKey = {
x: passkeyCredential.publicKey.x,
y: passkeyCredential.publicKey.y,
}
Store the passkey's public credentials (x, y, and passkeyCredential.id) in a retrievable location before the smart account is deployed. Losing this data prevents users from using their accounts if the account has not been. This information is not sensitive. You can use a simple backend server or leverage @simplewebauthn/server for storage.
Step 4: Initialize Smart Account
Initialize the Safe Smart Account with the passkey as the signer. SafeAccountV0_3_0 supports Entrypoint v0.7, while SafeAccountV0_2_0 supports Entrypoint v0.6.
import { SafeAccountV0_3_0 as SafeAccount } from "abstractionkit";
const smartAccount = SafeAccount.initializeNewAccount([webauthPublicKey])
Create and Sign a UserOperation
Step 5: Create UserOperation
Create a UserOperation following the standard Safe flow with createUserOperation. Pass expectedSigners so that gas estimation accounts for the passkey signature format.
let userOperation = await smartAccount.createUserOperation(
[transaction], // your MetaTransaction (to, value, data)
jsonRpcNodeProvider, // JSON-RPC node endpoint
bundlerUrl, // Bundler RPC endpoint
{
expectedSigners: [webauthPublicKey]
},
)
Step 6: Sign with Passkeys
Signing a UserOperation with passkeys involves four substeps: hashing, requesting a WebAuthn assertion, building the signature data, and formatting it.
Step 6a: Calculate the EIP-712 Hash
Compute the Safe EIP-712 hash for the UserOperation. This is the challenge that the passkey will sign.
const userOpHash = SafeAccount.getUserOperationEip712Hash(
userOperation,
BigInt(chainId),
);
Step 6b: Request a WebAuthn Assertion
Use the sign function from ox/WebAuthnP256 to prompt the user for biometric authentication. Pass the userOpHash as the challenge and the credential ID from Step 2.
import { sign } from 'ox/WebAuthnP256';
import { Hex as OxHex } from 'ox/Hex';
import { Bytes, Hex } from 'ox';
const { metadata, signature } = await sign({
challenge: userOpHash as OxHex,
credentialId: passkeyCredential.id as OxHex,
});
Step 6c: Build the WebauthnSignatureData
Extract the clientDataFields from the WebAuthn response metadata and construct the WebauthnSignatureData object that abstractionkit expects.
import { WebauthnSignatureData } from "abstractionkit";
// Extract the fields portion of clientDataJSON (everything after the challenge)
const clientDataMatch = metadata.clientDataJSON.match(
/^\{"type":"webauthn.get","challenge":"[A-Za-z0-9\-_]{43}",(.*)\}$/,
);
if (!clientDataMatch) {
throw new Error('Invalid clientDataJSON format: challenge not found');
}
const [, fields] = clientDataMatch;
const webauthnSignatureData: WebauthnSignatureData = {
authenticatorData: Bytes.fromHex(metadata.authenticatorData).buffer as ArrayBuffer,
clientDataFields: Hex.fromString(fields),
rs: [signature.r, signature.s],
};
const webauthnSignature: string = SafeAccount.createWebAuthnSignature(webauthnSignatureData)
Step 6d: Format the Signer Signature Pair
Create a SignerSignaturePair linking the public key to its signature, then format it into the UserOperation's expected signature field.
import { SignerSignaturePair } from "abstractionkit";
const signerSignaturePair: SignerSignaturePair = {
signer: webauthPublicKey,
signature: webauthnSignature,
}
userOperation.signature = SafeAccount.formatSignaturesToUseroperationSignature(
[signerSignaturePair],
{ isInit: userOperation.nonce == 0n },
)
Step 7: Submit Onchain
Send the signed UserOperation to the bundler and wait for it to be included onchain.
const sendUserOperationResponse = await smartAccount.sendUserOperation(
userOperation,
bundlerUrl,
);
// Wait for the transaction to be included in a block
const userOperationReceiptResult = await sendUserOperationResponse.included();
Troubleshooting
Here are common errors you may encounter when integrating passkeys:
-
"The operation either timed out or was not allowed": The user cancelled the WebAuthn prompt, or the browser does not support passkeys. Verify that you are serving over HTTPS (required for WebAuthn) and that the user's browser supports the Web Authentication API.
-
"Invalid signature" or gas estimation failure: This typically means a mismatch between the
expectedSignerspassed tocreateUserOperationand the actual signing key. Ensure you are using the samewebauthPublicKeyin both account initialization and UserOperation creation. -
Domain mismatch: The
rp.idvalue passed during credential creation must match the domain where the signing occurs. If you created credentials onlocalhostbut are signing on a deployed domain (or vice versa), the browser will reject the assertion. -
Passkey prompt does not appear: Ensure
authenticatorAttachmentis set to'platform'for built-in biometrics, or'cross-platform'for security keys. Some browsers require a user gesture (like a button click) before the WebAuthn prompt can appear.
Getting Help
If you're still stuck:
- Ask questions in abstractionkit's GitHub Discussions
- Join our Discord
Advanced
Multisig
New Account
To initialize a smart account with multiple signer types, provide both a WebAuthn public key and an EOA public key to the initialization function, with the WebAuthn public key listed first.
To add two Passkey signers, initialize and deploy the account with a single Passkey signer first, then use addOwnerWithThreshold to add the second Passkey signer.
import { SafeAccountV0_3_0 as SafeAccount } from "abstractionkit";
import { Wallet } from 'ethers'
const webauthPublicKey = .. // see Step 3
// EOA Signer
const eoaSigner = Wallet.createRandom();
const eoaPublicKey = eoaSigner.address;
let smartAccount = SafeAccount.initializeNewAccount(
[webauthPublicKey, eoaPublicKey],
{ threshold: 2 }
)
Existing account
- Add a Passkeys owner to an existing account using
createAddOwnerWithThresholdMetaTransactions
import { MetaTransaction } from "abstractionkit"
const addPasskeysOwner: MetaTransaction = await smartAccount.createAddOwnerWithThresholdMetaTransactions(
webauthPublicKey, // the x and y webAuthn publickey
1, // threshold
{ nodeRpcUrl: nodeUrl }
);
- Swap an existing owner to a Passkeys owner using
createSwapOwnerMetaTransactions
import { MetaTransaction } from "abstractionkit"
const swapOwnerWithPasskeys: MetaTransaction = await smartAccount.createSwapOwnerMetaTransactions(
nodeUrl,
webauthPublicKey, // the x and y webAuthn publickey
oldOwnerPublicKey, // the old owner to replace
);
Create UserOp
To obtain accurate gas estimates, pass the expected signers who will sign the UserOperation in the createUserOperation overrides.
let userOperation = await smartAccount.createUserOperation(
[metaTransaction],
jsonRpcNodeProvider,
bundlerUrl,
{
expectedSigners:[webauthPublicKey, eoaPublicKey],
}
)
Signature
To sign a transaction with multiple signers, pass the signer signature pairs to formatSignaturesToUseroperationSignature.
const eoaSignature = eoaSigner.signingKey.sign(userOpHash).serialized;
const eoaSignerSignaturePair: SignerSignaturePair = {
signer: eoaPublicKey,
signature: eoaSignature,
}
userOperation.signature = SafeAccount.formatSignaturesToUseroperationSignature(
[webAuthnSignerSignaturePair, eoaSignerSignaturePair],
{ isInit: userOperation.nonce == 0n }
);
Gas Savings with Precompiles
Leverage Native Passkeys with RIP-7212 when supported for optimal gas efficiency. Import the default precompile address and pass it in the overrides. Verify that your chain has adopted the same precompile address specified in the standard.
Chains that support the RIP-7212 precompile for secp256r1 verification can reduce passkey signature validation costs significantly. Check your target chain's documentation to confirm support before enabling.
New Account
import { SafeAccountV0_3_0 as SafeAccount, DEFAULT_SECP256R1_PRECOMPILE_ADDRESS } from "abstractionkit";
let smartAccount = SafeAccount.initializeNewAccount(
[webauthPublicKey],
{ eip7212WebAuthnPrecompileVerifierForSharedSigner: DEFAULT_SECP256R1_PRECOMPILE_ADDRESS }
)
Create UserOp
let userOperation = await smartAccount.createUserOperation(
[metaTransaction],
nodeRPC,
bundlerURL,
{
expectedSigners:[webauthPublicKey],
eip7212WebAuthnPrecompileVerifier: DEFAULT_SECP256R1_PRECOMPILE_ADDRESS
}
);
Signature
userOperation.signature = SafeAccount.formatSignaturesToUseroperationSignature(
[webauthnSignerSignaturePair],
{
isInit: userOperation.nonce == 0n,
eip7212WebAuthnPrecompileVerifier: DEFAULT_SECP256R1_PRECOMPILE_ADDRESS,
}
);
Verifying a WebAuthn Signature
Validate WebAuthn signatures to verify whether a signature on behalf of a given Safe Account is valid, similar to EOA owner verification.
- Sign a message hash using the standard process:
import { hashMessage } from "ethers";
const messageHashed = hashMessage("Hello World");
const assertion = navigator.credentials.get({
publicKey: {
challenge: ethers.getBytes(messageHashed),
rpId: "candide.dev",
allowCredentials: [
{ type: "public-key", id: new Uint8Array(credential.rawId) },
],
userVerification: UserVerificationRequirement.required,
},
});
const webauthSignatureData: WebauthnSignatureData = {
authenticatorData: assertion.response.authenticatorData,
clientDataFields: extractClientDataFields(assertion.response),
rs: extractSignature(assertion.response),
};
const webauthnSignature: string = SafeAccount.createWebAuthnSignature(webauthSignatureData);
- Validating a signed webAuthn message
verifyWebAuthnSignatureForMessageHashParam
const isSignatureValid: boolean =
await SafeAccount.verifyWebAuthnSignatureForMessageHash(
nodeURL, // node url from a json rpc provider
webauthPublicKey, // the x and y webAuthn publickey
messageHashed,
webauthnSignature
);
For a complete example to sign and verify message, run the repo safe-passkeys-sign-and-verify-message
Additional Notes
WebAuthn / Passkeys API
The WebAuthn API is a web standard that enables passwordless authentication, allowing users to sign in to websites and applications using biometric factors (e.g., fingerprint, face recognition) or security keys. This API is supported by most major browsers, including Google Chrome, Mozilla Firefox, Microsoft Edge, Apple Safari, Brave, and Opera. For more information on browser support, Mozilla has created great documentation on WebAuthn.
In this guide, we use the ox library, which provides a high-level abstraction over the WebAuthn API via the ox/WebAuthnP256 module.
React Native Integration
For React Native applications, teams have used the following libraries to integrate WebAuthn/Passkeys:
- react-native-passkey: A React Native wrapper around the platform-specific WebAuthn/Passkeys APIs.
- cbor-web: Used in conjunction with
react-native-passkeyto handle the CBOR (Concise Binary Object Representation) data format used by the WebAuthn API. - React Native demo by Adrian, the lead developer from Unit-e, using
abstractionkit,react-native-passkey, andcbor-web.
Sync & Recovery
Apple
Passkey recovery on Apple devices uses iCloud Keychain escrow. In case of device loss, users authenticate through their iCloud account using standard procedures, then enter their device passcode. Apple users can also add an account recovery contact for additional support. Learn more about Apple Passkeys security.
Google
Google Password Manager seamlessly syncs passkeys across devices, with plans to extend syncing support to additional operating systems. Learn more about Google Passkeys security.
YubiKey
YubiKey supports passkeys through its authentication protocol implementation. Passkeys can be protected and managed using YubiKey's hardware-based security features. Learn more at Yubico.
Password Managers
Passkey backups extend beyond hardware manufacturers. They are supported across various password managers including Windows Hello, Bitwarden, Proton Pass, 1Password, LastPass, and others.
Device Support
Passkeys are widely available across devices such as:
- Apple Devices: iPhones & iPads (iOS 16+), Mac (macOS 13+)
- Android Devices: Phones and tablets (Android 9+)
- Windows (10/11/+): Supported on Chrome, Brave, Edge, and Firefox browsers
- Linux: Supported on Chrome, Firefox, Edge, and Brave browsers
For a comprehensive list of supported systems, please visit passkeys.dev/device-support
What's Next?
Now that you've integrated passkeys authentication, explore these features to harden your setup:
Recommended Next Steps
- Recovery Module: Protect passkey-only accounts with social recovery guardians
- Gas Sponsorship: Sponsor gas fees for your users with a Paymaster
- Multisig Setup: Add a second signer for production-grade security (recommended for any 1/1 passkey account)
- Pay Gas in ERC-20: Let users pay transaction fees with stablecoins or other tokens