Advanced
Passkeys
Tutorials
Build a Vue app with Safe and passkeys

How to build a Vue app with Safe and passkeys

⚠️

Because of known compatibility issues with Mozilla Firefox's implementation of passkeys, we recommend using Google Chrome or Chromium to follow this tutorial.

An increasing number of applications rely on passkeys to authenticate users securely and with little friction. Security and user-friendliness are crucial to making web3 a reality for the next billion users. Being able to unlock a Safe Smart Account with your fingerprints or Face ID, sending transactions without worrying about third-party wallet interfaces, phishing attempts, or securing seed phrases will bring new forms of ownership to the connected world. Today, we'll learn how to make this a reality using Safe{Core} SDK, Pimlico (opens in a new tab), and Nuxt (opens in a new tab).

This tutorial will demonstrate creating a web app for using passkeys in your Safe. This app will allow you to:

  • Create a new passkey secured by the user's device.
  • Deploy a new Safe on Ethereum Sepolia for free.
  • Sign a transaction to mint an NFT using the previously created passkey.

safe-passkeys-app-1.png

What you'll need

Prerequisite knowledge: You will need some basic experience with Vue (opens in a new tab), Nuxt, and ERC-4337.

Before progressing with the tutorial, please make sure you have:

Note: If you wish to follow along using the completed project, you can check out the GitHub repository (opens in a new tab) for this tutorial.

1. Setup a Nuxt application

Initialize a new Nuxt app using pnpm with the following command:

pnpm dlx nuxi@latest init safe-passkeys-nuxt -t ui

When prompted by the CLI, select pnpm and yes to initialize a Git repository.

Install dependencies

For this project, we'll use the Relay Kit and Protocol Kit from the Safe{Core} SDK to set up a Safe, sponsor a transaction, and use viem (opens in a new tab) for a helper function to encode the dummy transaction. We will also use @pinia/nuxt (opens in a new tab) for state management and node polyfills from Vite (opens in a new tab).

Run the following command to add all these dependencies to the project:

pnpm add @safe-global/protocol-kit@4.1.0 @safe-global/relay-kit@3.1.0 viem @pinia/nuxt vite-plugin-node-polyfills

Replace the content of nuxt.config.ts with the following code:

// https://nuxt.com/docs/api/configuration/nuxt-config
import { nodePolyfills } from 'vite-plugin-node-polyfills'

export default defineNuxtConfig({
compatibilityDate: '2024-04-03',
devtools: { enabled: true },
modules: ['@nuxt/ui', '@pinia/nuxt'],
vite: {
plugins: [nodePolyfills()]
},
runtimeConfig: {
public: {
NUXT_PUBLIC_PIMLICO_API_KEY: process.env.NUXT_PUBLIC_PIMLICO_API_KEY
}
}
})

Now, create a file named .env at the root of your project, and add your Pimlico API key to it:

echo "NUXT_PUBLIC_PIMLICO_API_KEY='your_pimlico_api_key_goes_here'" > .env

Run the development server

Run the local development server with the following command:

pnpm dev

Go to http://localhost:3000 in your browser to see the default Nuxt application.

next.png

2. Add project constants and utilities

Create a utils folder at the project root and add a file constants.ts containing common constants used throughout the project:

mkdir utils
cd utils
touch constants.ts

Add the following code to the constants.ts file:

export const STORAGE_PASSKEY_LIST_KEY = 'safe_passkey_list'
export const RPC_URL = 'https://ethereum-sepolia-rpc.publicnode.com'
export const CHAIN_NAME = 'sepolia'
export const PAYMASTER_ADDRESS = '0x0000000000325602a77416A16136FDafd04b299f' // SEPOLIA
export const BUNDLER_URL = `https://api.pimlico.io/v1/${CHAIN_NAME}/rpc?add_balance_override&apikey=`
export const PAYMASTER_URL = `https://api.pimlico.io/v2/${CHAIN_NAME}/rpc?add_balance_override&apikey=`
export const NFT_ADDRESS = '0xBb9ebb7b8Ee75CDBf64e5cE124731A89c2BC4A07'

3. Add passkeys functionality

In the utils folder, create a file called passkeys.ts:

touch passkeys.ts

This file will contain all the logic required to operate passkey:

  • Create and recover them using the user's device.
  • Store and retrieve them from/to the local storage.

Note: You can also store the passkeys on a remote database or the user's device.

import {
type PasskeyArgType,
extractPasskeyData
} from '@safe-global/protocol-kit'

/**
* Create a passkey using WebAuthn API.
* @returns {Promise<PasskeyArgType>} Passkey object with rawId and coordinates.
* @throws {Error} If passkey creation fails.
*/
export async function createPasskey(): Promise<PasskeyArgType> {
const displayName = 'Safe Owner' // This can be customized to match, for example, a user name.
// Generate a passkey credential using WebAuthn API
const passkeyCredential = await navigator.credentials.create({
publicKey: {
pubKeyCredParams: [
{
// ECDSA w/ SHA-256: https://datatracker.ietf.org/doc/html/rfc8152#section-8.1
alg: -7,
type: 'public-key'
}
],
challenge: crypto.getRandomValues(new Uint8Array(32)),
rp: {
name: 'Safe SmartAccount'
},
user: {
displayName,
id: crypto.getRandomValues(new Uint8Array(32)),
name: displayName
},
timeout: 60_000,
attestation: 'none'
}
})

if (!passkeyCredential) {
throw Error('Passkey creation failed: No credential was returned.')
}

const passkey = await extractPasskeyData(passkeyCredential)
console.log('Created Passkey: ', passkey)

return passkey
}

/**
* Store passkey in local storage.
* @param {PasskeyArgType} passkey - Passkey object with rawId and coordinates.
*/
export function storePasskeyInLocalStorage(passkey: PasskeyArgType) {
const passkeys = loadPasskeysFromLocalStorage()

passkeys.push(passkey)

localStorage.setItem(STORAGE_PASSKEY_LIST_KEY, JSON.stringify(passkeys))
}

/**
* Load passkeys from local storage.
* @returns {PasskeyArgType[]} List of passkeys.
*/
export function loadPasskeysFromLocalStorage(): PasskeyArgType[] {
const passkeysStored = localStorage.getItem(STORAGE_PASSKEY_LIST_KEY)

const passkeyIds = passkeysStored ? JSON.parse(passkeysStored) : []

return passkeyIds
}

/**
* Get passkey object from local storage.
* @param {string} passkeyRawId - Raw ID of the passkey.
* @returns {PasskeyArgType} Passkey object.
*/
export function getPasskeyFromRawId(passkeyRawId: string): PasskeyArgType {
const passkeys = loadPasskeysFromLocalStorage()

const passkey = passkeys.find((passkey) => passkey.rawId === passkeyRawId)!

return passkey
}

In this file, we have four functions:

  • createPasskey, which helps create a new passkey.
  • storePasskeyInLocalStorage, which helps store it in the browser's local storage.
  • loadPasskeysFromLocalStorage, which helps load a passkey from local storage.
  • getPublicKeyFromLocalStorage, which helps find a passkey in the local storage corresponding to a given rawId and returns this passkey's public key.
  • getPasskeyFromRawId, which helps reconstruct a full passkey from a rawId and a public key stored in local storage.

4. Add mint NFT functionality

Create a mintNFT.ts file in the utils folder to add functions to prepare and send a transaction minting an NFT from our yet-to-come Safe.

touch mintNFT.ts

Add the following code to the mintNFT.ts file:

import type { PasskeyArgType } from '@safe-global/protocol-kit'
import { Safe4337Pack } from '@safe-global/relay-kit'
import { encodeFunctionData } from 'viem'

/**
* Mint an NFT.
* @param {PasskeyArgType} signer - Signer object with rawId and coordinates.
* @param {string} safeAddress - Safe address.
* @returns {Promise<void>}
* @throws {Error} If the operation fails.
*/
export const mintNFT = async (passkey: PasskeyArgType, safeAddress: string) => {
const runtimeConfig = useRuntimeConfig()

// 1) Initialize Safe4337Pack
const paymasterOptions = {
isSponsored: true,
paymasterAddress: PAYMASTER_ADDRESS,
paymasterUrl:
PAYMASTER_URL + runtimeConfig.public.NUXT_PUBLIC_PIMLICO_API_KEY
}

const safe4337Pack = await Safe4337Pack.init({
provider: RPC_URL,
signer: passkey,
bundlerUrl: BUNDLER_URL + runtimeConfig.public.NUXT_PUBLIC_PIMLICO_API_KEY,
paymasterOptions,
options: {
owners: [
/* Other owners... */
],
threshold: 1
}
})

// 2) Create SafeOperation
const mintNFTTransaction = {
to: NFT_ADDRESS,
data: encodeSafeMintData(safeAddress),
value: '0'
}

const safeOperation = await safe4337Pack.createTransaction({
transactions: [mintNFTTransaction]
})

// 3) Sign SafeOperation
const signedSafeOperation =
await safe4337Pack.signSafeOperation(safeOperation)

console.log('SafeOperation', signedSafeOperation)

// 4) Execute SafeOperation
const userOperationHash = await safe4337Pack.executeTransaction({
executable: signedSafeOperation
})

return userOperationHash
}

/**
* Encodes the data for a safe mint operation.
* @param to The address to mint the token to.
* @param tokenId The ID of the token to mint.
* @returns The encoded data for the safe mint operation.
*/
export function encodeSafeMintData(
to: string,
tokenId: bigint = getRandomUint256()
): string {
return encodeFunctionData({
abi: [
{
constant: false,
inputs: [
{
name: 'to',
type: 'address'
},
{
name: 'tokenId',
type: 'uint256'
}
],
name: 'safeMint',
payable: false,
stateMutability: 'nonpayable',
type: 'function'
}
],
functionName: 'safeMint',
args: [to, tokenId]
})
}

/**
* Generates a random 256-bit unsigned integer.
*
* @returns {bigint} A random 256-bit unsigned integer.
*
* This function uses the Web Crypto API's `crypto.getRandomValues()` method to generate
* a uniformly distributed random value within the range of 256-bit unsigned integers
* (from 0 to 2^256 - 1).
*/
function getRandomUint256(): bigint {
const dest = new Uint8Array(32) // Create a typed array capable of storing 32 bytes or 256 bits

crypto.getRandomValues(dest) // Fill the typed array with cryptographically secure random values

let result = 0n
for (let i = 0; i < dest.length; i++) {
result |= BigInt(dest[i]) << BigInt(8 * i) // Combine individual bytes into one bigint
}

return result
}

With this configuration, a new Safe will be created (but not yet deployed) when a passkey is selected. This Safe will be deployed when its first transaction is executed.

Note: Minting an NFT was chosen here just as an example, and any other transaction would have the same effect.

5. Add a state store

We will use Pinia to manage the state of our app. Pinia allows to gracefully handle state changes across wider applications, which is why we'll be using it for this tutorial.

Create a stores folder at the project root and add a file safe.ts:

cd ..
mkdir stores
cd stores
touch safe.ts

Add the following code to the safe.ts file:

import type { PasskeyArgType } from '@safe-global/protocol-kit'

export const useSafeStore = defineStore('safe', {
state: () => ({
passkeys: <PasskeyArgType[]>[],
selectedPasskey: <PasskeyArgType>{},
safeAddress: <string>'',
isSafeDeployed: <boolean>false,
isLoading: <boolean>false,
userOp: <string>'',
jiffyLink: <string>'',
safeLink: <string>''
}),
actions: {
setPasskeys(data: PasskeyArgType[]) {
this.passkeys = data
},
setSelectedPasskey(data: PasskeyArgType) {
this.selectedPasskey = data
},
setSafeAddress(data: string) {
this.safeAddress = data
},
setIsSafeDeployed(data: boolean) {
this.isSafeDeployed = data
},
setIsLoading(data: boolean) {
this.isLoading = data
},
setUserOp(data: string) {
this.userOp = data
},
setSafeLink(data: string) {
this.safeLink = data
},
setJiffyLink(data: string) {
this.jiffyLink = data
}
}
})

6. Add UI components

Let's add a user interface to create and store a passkey on the user's device, deploy a safe, and sign the NFT transaction.

Create a components folder at the project root, and create a file named LoginWithPasskey.vue:

mkdir ../components
cd ../components
touch LoginWithPasskey.vue

Add the following code to the LoginWithPasskey.vue file:

<script setup lang="ts">
import { useSafeStore } from '@/stores/safe'
import { Safe4337Pack } from '@safe-global/relay-kit'

import type { PasskeyArgType } from '@safe-global/protocol-kit'

const store = useSafeStore()
const runtimeConfig = useRuntimeConfig()

async function handleCreatePasskey() {
const passkey = await createPasskey()

storePasskeyInLocalStorage(passkey)
store.setSelectedPasskey(passkey)

await showSafeInfo(passkey)
}

async function selectExistingPasskey() {
const passkeys = loadPasskeysFromLocalStorage()

store.setPasskeys(passkeys)
store.setSelectedPasskey(passkeys[0])

await showSafeInfo(store.selectedPasskey)
}

async function showSafeInfo(passkey: PasskeyArgType) {
store.setIsLoading(true)
const safe4337Pack = await Safe4337Pack.init({
provider: RPC_URL,
signer: passkey,
bundlerUrl: BUNDLER_URL + runtimeConfig.public.NUXT_PUBLIC_PIMLICO_API_KEY,
options: {
owners: [],
threshold: 1
}
})
store.setSafeAddress(await safe4337Pack.protocolKit.getAddress())
store.setIsSafeDeployed(await safe4337Pack.protocolKit.isSafeDeployed())
store.setIsLoading(false)
}
</script>

<template>
<div
v-if="Object.keys(store.selectedPasskey).length === 0"
class="mt-20 dark:bg-stone-800 bg-stone-50 p-8 rounded w-fit flex flex-col items-center"
>
<h1 class="text-4xl text-[#12FF80]">Use Safe Account via Passkeys</h1>
<h2 class="my-12">Create a new Safe using Passkeys</h2>
<UButton
icon="material-symbols:fingerprint"
block
class="mb-8"
variant="outline"
@click="handleCreatePasskey"
>
Create a new passkey
</UButton>
<UDivider label="OR" :ui="{ border: { base: 'dark:border-gray-500' } }" />
<h2 class="my-12">Connect existing Safe using an existing passkey</h2>
<UButton
icon="material-symbols:fingerprint"
block
@click="selectExistingPasskey"
>Use an existing passkey</UButton
>
</div>
</template>

This component is an authentication modal allowing users to either signing in by creating a new passkey, or logging in with an existing one.

Next, create a SafeAccountDetails.vue file in the same folder:

touch SafeAccountDetails.vue

Add the following code to the SafeAccountDetails.vue file:

<script setup lang="ts">
import { useSafeStore } from '@/stores/safe'

const store = useSafeStore()

async function handleMintNFT() {
store.setIsLoading(true)

const userOp = await mintNFT(store.selectedPasskey, store.safeAddress!)

store.setIsLoading(false)
store.setIsSafeDeployed(true)
store.setUserOp(userOp)
store.setJiffyLink(
`https://jiffyscan.xyz/userOpHash/${userOp}?network=${CHAIN_NAME}`
)
store.setSafeLink(
`https://app.safe.global/home?safe=sep:${store.safeAddress}`
)
}

const DEFAULT_CHAR_DISPLAYED = 6

function splitAddress(
address: string,
charDisplayed: number = DEFAULT_CHAR_DISPLAYED
): string {
const firstPart = address.slice(0, charDisplayed)
const lastPart = address.slice(address.length - charDisplayed)

return `${firstPart}...${lastPart}`
}
</script>

<template>
<div
v-if="Object.keys(store.selectedPasskey).length !== 0"
class="mt-20 dark:bg-stone-800 bg-stone-50 p-8 rounded w-fit flex flex-col items-center"
>
<h1 class="text-4xl text-[#12FF80]">Your Safe Accout</h1>
<UIcon
v-if="store.isLoading"
name="line-md:loading-loop"
class="mt-4 w-12 h-12"
/>
<div v-if="!store.isLoading" class="flex flex-col items-center">
<UButton
variant="link"
color="white"
v-if="store.safeAddress"
class="my-8"
:to="store.safeLink"
target="_blank"
rel="noopener noreferrer"
>
<template #leading
><UIcon name="token:safe" class="h-8 w-8" /> </template
>{{ splitAddress(store.safeAddress) }}
<template #trailing
><UIcon name="tabler:external-link" class="w-5 h-5" />
</template>
</UButton>
<UBadge
v-if="store.safeAddress && !store.isSafeDeployed"
color="yellow"
variant="solid"
>Deployment pending
</UBadge>
<UButton
variant="outline"
v-if="store.safeAddress"
icon="material-symbols:image-outline"
class="mt-8 ml-2 mr-2"
@click="handleMintNFT"
>
Mint NFT</UButton
>
<UButton
variant="link"
color="white"
v-if="store.userOp"
class="my-8"
:to="store.jiffyLink"
target="_blank"
rel="noopener noreferrer"
>{{ store.userOp }}
<template #trailing>
<UIcon name="tabler:external-link" class="w-5 h-5" /> </template
></UButton>
</div>
</div>
</template>

This component displays the details of the Safe account, including the Safe address, whether it is deployed, and a button to mint the NFT.

Lastly, replace the content of the app.vue file at the project root with this code:

<template>
<NuxtRouteAnnouncer />
<NuxtLayout name="default">
<LoginWithPasskey />
<SafeAccountDetails />
</NuxtLayout>
</template>

This UI will put everything we built in the previous steps into a coherent application with all the functionality required to let you create a passkey, select it, and use it to sign a transaction.

7. Add styling

Because a web app is nothing without good styling, let's add some Safe design to our project 💅.

Create a layouts folder, and inside it create a new file default.vue:

mkdir ../layouts
cd ../layouts
touch default.vue

Add this code to the default.vue file:

<template>
<div class="p-6">
<header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<UIcon name="SafeIcon" class="dark:white black h-9 w-24" />
</div>
<div class="flex items-center">
<UButton
label="Button"
variant="link"
color="white"
to="https://docs.safe.global/home/passkeys-tutorials/safe-passkeys-nuxt"
target="_blank"
rel="noopener noreferrer"
>
Read tutorial
<template #trailing>
<UIcon
name="tabler:external-link"
class="dark:white black h-6 w-6"
/>
</template>
</UButton>
<UButton
label="Button"
variant="link"
color="white"
to="https://github.com/5afe/safe-passkeys-nuxt"
target="_blank"
rel="noopener noreferrer"
>
View on GitHub
<template #trailing>
<UIcon name="uil:github" class="dark:white black h-6 w-6" />
</template>
</UButton>
</div>
</div>
</header>
<main class="flex justify-center">
<slot />
</main>
</div>
</template>

Testing your Safe passkeys app

That's it! You can find the source code for the example created in this tutorial on GitHub (opens in a new tab). You can now return to your browser and see the app displayed 🎉.

safe-passkeys-app-1.png

Click the Create a new passkey button to prompt a browser pop-up asking you to confirm the creation of a new passkey. This passkey will be stored in your browser's local storage and displayed in the list above the button.

This will create a new Safe object in the background, which will be deployed when you click the Mint NFT button. This will also mint an NFT using the passkey you created.

safe-passkeys-app-3.png

Click the link to Jiffy Scan to see the UserOp that was sent and more complete information.

Best practices

Please be mindful of certain security considerations when dealing with passkeys. For the tutorial's simplicity, we created a 1/1 Safe with a passkey as the sole signer. This is not recommended for production setups, as passkeys are tied to a domain name, and they can also be tied to hardware manufacturers. For that reason, they might become inaccessible if not configured or saved properly.

Note: Please always use a combination of passkeys and other authentication methods to ensure the security of your users' assets.

Do more with Safe and passkeys

We learned how to use passkeys (create them, store them, and use them securely) and how they can interact with a Safe (deploy it and send transactions). We hope you enjoyed this tutorial and that the combination of passkeys and the ERC-4337 will unlock new forms of ownership for your project and users.

You can now integrate passkeys with more transactions and functionalities of the Safe ecosystem. You can read more about passkeys in our overview or in the WebAuthn API documentation (opens in a new tab).

Did you encounter any difficulties? Let us know by opening an issue (opens in a new tab) or asking a question on Stack Exchange (opens in a new tab) with the safe-core tag.

Last updated on

Was this page helpful?

We use cookies to provide you with the best experience and to help improve our website and application. Please read our Cookie Policy for more information. By clicking "Accept all", you agree to the storing of cookies on your device to enhance site navigation, analyze site usage and provide customer support.