Skip to main content

How to Add Support for Passkeys Authentication

Passkeys can be used to secure onchain smart accounts using fingerprint, face recognition, or the device PIN code. Users no longer need to manage and remember complex private keys; instead, they can effortlessly access their digital wallets using passkey-enabled devices, synced across devices via Apple's iCloud Keychain, or other cross-platform password managers like Proton Pass and Bitwarden.

In the context of an Ethereum wallet, Passkeys serve as substitutes for traditional seedphrase backups. Unlike the default curve (secp256k1) used for Externally Owned Accounts (EOA), Passkeys generate unique digital keys using the secp256r1 curve. These keys benefit from device secure enclave cryptography, which enhances security by preventing password reuse. Passkeys are built on the WebAuthn standard, leveraging public-key cryptography. Developed collaboratively by the FIDO Alliance, which includes Apple, Google, Microsoft, and others, Passkeys strictly adhere to WebAuthn standards.

Safe Passkeys

The Safe Passkeys contracts are developed by the Safe Protocol Team. The contracts and their audits are available on the Safe-Modules repo. The deployment addresses can be found in our contract deployment page.

Demo

These example showcases a Safe Smart Account deployment utilizing 4337 and Passkeys

Quick start

Create Passkeys Account

npm i abstractionkit ox

Step 1: Create WebAuthn credentials

Create WebAuthn credentials using the createCredential function from ox/WebAuthnP256. This function simplifies the process by handling the interaction with the browser WebAuthn API.

import { createCredential } from 'ox/WebAuthnP256'

const passkeyCredential = await createCredential({
name: 'Safe Wallet',
challenge: crypto.getRandomValues(new Uint8Array(32)),
rp: {
id: window.location.hostname,
name: 'Safe Wallet'
},
authenticatorSelection: {
authenticatorAttachment: 'platform',
residentKey: 'required',
userVerification: 'required',
},
timeout: 60000,
attestation: 'none',
})

Step 2: Extract Public Key

The createCredential function directly returns the public key coordinates, so no extra extraction step is needed.

import { WebauthPublicKey } from "abstractionkit";

const webauthPublicKey: WebauthPublicKey = {
x: passkeyCredential.publicKey.x,
y: passkeyCredential.publicKey.y,
}

Step 3: Initialize Smart Account

Initialize the Safe Smart Account as usual. The 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 Passkey UserOp

This step follows the same flow as the normal Safe flow with createUserOperation, with the addition of the expectedSigners overrides


let userOperation = await smartAccount.createUserOperation(
[transaction] // constructed your MetaTranasction
jsonRpcNodeProvider, //The Node rpc endpoint.
bundlerUrl, //The Bundler rpc endpoint.
{
expectedSigners: [webauthPublicKey]
},
)

Sign with Passkeys

Step 1: Calculate the EIP712 hash

Calculate the Safe EIP712 hash for the UserOp.

const userOpHash = SafeAccount.getUserOperationEip712Hash(
userOperation,
BigInt(chainId),
);

Step 2: Request a WebAuthn assertion

Use the sign function from ox/WebAuthnP256 to sign the user operation hash. This function takes the challenge (the user operation hash) and the credentialId of the passkey.

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 3: Create the WebauthnSignatureData

The sign function returns the metadata and signature needed to construct the WebauthnSignatureData object for abstractionkit.

import { WebauthnSignatureData } from "abstractionkit";

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 4: Create a Signer Signature Pair and Format

Create a SignerSignaturePair containing the webauthPublicKey and webauthSignature, and format the SignerSignaturePair into the expected format for the userOperation signature

import { SignerSignaturePair } from "abstractionkit";

const signerSignaturePair: SignerSignaturePair = {
signer: webauthPublicKey,
signature: webauthnSignature,
}

userOperation.signature = SafeAccount.formatSignaturesToUseroperationSignature(
[signerSignaturePair],
{ isInit: userOperation.nonce == 0n },
)

Submit the UserOp onchain

const sendUserOperationResponse = await smartAccount.sendUserOperation(
userOperation,
bundlerUrl,
);
const userOperationReceiptResult = await sendUserOperationResponse.included();

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, ensuring the WebAuthn public key is listed first. If you need to add two Passkey signers, start by initializing and deploying the account with a single Passkey signer. Then, use the addOwnerWithThreshold function to add the second Passkey signer.

import { SafeAccountV0_3_0 as SafeAccount } from "abstractionkit";
import { Wallet } from 'ethers'

const webauthPublicKey = .. // see above

// EOA Signer
const eoaSigner = Wallet.createRandom();
const eoaPublicKey = eoaSigner.address;

let smartAccount = SafeAccount.initializeNewAccount(
[webauthPublicKey, eoaPublicKey],
{ threshold: 2 }
)

Existing account

import { MetaTransaction } from "abstractionkit"

const addPasskeysOwner: MetaTransaction = await smartAccount.createAddOwnerWithThresholdMetaTransactions(
webauthPublicKey, // the x and y webAuthn publickey
1, // threshold
{ nodeRpcUrl: nodeUrl }
);
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 get proper gas estimates, you can pass the expected signers that will sign over the userOp in the overrides of createUserOperation.

let userOperation = await smartAccount.createUserOperation(
[metaTransaction],
jsonRpcNodeProvider,
bundlerUrl,
{
expectedSigners:[webauthPublicKey, eoaPublicKey],
}
)

Signature

To sign a transaction with multiple signers, you will need to pass in the sign signature pair in the 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

You can leverage Native Passkeys with RIP-7212 when supported, as it offers the lowest gas costs. You can import the default precompile address and pass it in the overrides. We recommend verifying whether the chain you are using has adopted the same address as specified in the standard.

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

You can validate webAuthn signature to verify whether a signature on a behalf of a given Safe Account is valid, similar to EOA owners.

  • Signing a message hash is as usual
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);
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 the browser support and requirements, Mozilla has created great documentation on WebAuthn.

Web-based:

In this guide, we use the 0x library, which provides a high-level abstraction over the WebAuthn API. This simplifies the integration process by handling the low-level details of the WebAuthn API.

  • ox/WebAuthnP256: This is the specific module from the 0x library used in this guide. It provides functions for creating and using P-256 credentials, which are the standard for passkeys.

React Native:

For React Native applications, teams have used the following libraries to integrate the WebAuthn/Passkeys functionality:

  • react-native-passkey: This library provides a React Native wrapper around the platform-specific WebAuthn/Passkeys APIs.
  • cbor-web: This library is used in conjunction with react-native-passkey to 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, and cbor-web.

Security Consideration

It’s important to consider a multisig setup or the recovery module when using Passkeys. In this demo, we demonstrated a simple setup by creating a 1/1 Safe with a passkey as the only signer. While straightforward, this approach is not suitable for production environments. Passkeys are tied to specific domain names and, in some cases, associated with particular hardware manufacturers. This dependency introduces several vulnerabilities that could make user accounts inaccessible under certain circumstances, such as:

  1. Device Migration: Users switching from iPhone to Android, Windows to macOS, or vice versa, may lose access if the Passkey is tied to a specific device or ecosystem.
  2. Domain Issues: If your domain is compromised, becomes unavailable, or is no longer maintained, Passkey authentication may fail.

Our recommendation: include at lest a second way to access Safe. Either adding a different backup signer (1/2 Safe), or add a Recovery method using the recovery module.

Saving Public Credentials

It's crucial to store the Passkey's public credentials, specifically the x, y, and rawId, in a retrievable location. Losing this data would mean users can't recover their accounts with Passkeys. This information isn't sensitive, so you can set up a simple server to do so, or you can you use@simplewebauthn/server for this purpose.

Sync & Recovery

Apple

Passkey recovery on Apple devices involves iCloud Keychain escrow. In case of device loss, users authenticate through their iCloud account using standard procedure. After authentication, they enter their device passcode. Apple users also has the option to add an account recovery contact for additional support. Learn more on Apple Passkeys security

Google

Google Password Manager seamlessly syncs passkeys across devices, with plans to extend syncing support to a broader range of operating systems. Learn more on Google Passkeys security

Yubikey

YubiKey is compatible with passkeys through its support for the authentication protocol. Passkeys can be protected and managed using YubiKey's hardware-based security features. Learn more on Yubico

Password Managers

Passkey backups are not limited to hardware manufactures, they are supported across different password managers like Windows Hello, Bitwarden, ProtonPass, 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