Home
Tutorials
Build a Vue app with Safe and passkeys

How to build a Vue app with Safe and passkeys

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:


_10
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:


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

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


_16
// https://nuxt.com/docs/api/configuration/nuxt-config
_16
import { nodePolyfills } from 'vite-plugin-node-polyfills'
_16
_16
export default defineNuxtConfig({
_16
compatibilityDate: '2024-04-03',
_16
devtools: { enabled: true },
_16
modules: ['@nuxt/ui', '@pinia/nuxt'],
_16
vite: {
_16
plugins: [nodePolyfills()]
_16
},
_16
runtimeConfig: {
_16
public: {
_16
NUXT_PUBLIC_PIMLICO_API_KEY: process.env.NUXT_PUBLIC_PIMLICO_API_KEY
_16
}
_16
}
_16
})

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


_10
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:


_10
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:


_10
mkdir utils
_10
cd utils
_10
touch constants.ts

Add the following code to the constants.ts file:


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

3. Add passkeys functionality

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


_10
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.


_81
import { type PasskeyArgType, extractPasskeyData } from '@safe-global/protocol-kit'
_81
_81
/**
_81
* Create a passkey using WebAuthn API.
_81
* @returns {Promise<PasskeyArgType>} Passkey object with rawId and coordinates.
_81
* @throws {Error} If passkey creation fails.
_81
*/
_81
export async function createPasskey (): Promise<PasskeyArgType> {
_81
const displayName = 'Safe Owner' // This can be customized to match, for example, a user name.
_81
// Generate a passkey credential using WebAuthn API
_81
const passkeyCredential = await navigator.credentials.create({
_81
publicKey: {
_81
pubKeyCredParams: [
_81
{
_81
// ECDSA w/ SHA-256: https://datatracker.ietf.org/doc/html/rfc8152#section-8.1
_81
alg: -7,
_81
type: 'public-key'
_81
}
_81
],
_81
challenge: crypto.getRandomValues(new Uint8Array(32)),
_81
rp: {
_81
name: 'Safe SmartAccount'
_81
},
_81
user: {
_81
displayName,
_81
id: crypto.getRandomValues(new Uint8Array(32)),
_81
name: displayName
_81
},
_81
timeout: 60_000,
_81
attestation: 'none'
_81
}
_81
})
_81
_81
if (!passkeyCredential) {
_81
throw Error('Passkey creation failed: No credential was returned.')
_81
}
_81
_81
const passkey = await extractPasskeyData(passkeyCredential)
_81
console.log("Created Passkey: ", passkey)
_81
_81
return passkey
_81
}
_81
_81
/**
_81
* Store passkey in local storage.
_81
* @param {PasskeyArgType} passkey - Passkey object with rawId and coordinates.
_81
*/
_81
export function storePasskeyInLocalStorage(passkey: PasskeyArgType) {
_81
const passkeys = loadPasskeysFromLocalStorage()
_81
_81
passkeys.push(passkey)
_81
_81
localStorage.setItem(STORAGE_PASSKEY_LIST_KEY, JSON.stringify(passkeys))
_81
}
_81
_81
/**
_81
* Load passkeys from local storage.
_81
* @returns {PasskeyArgType[]} List of passkeys.
_81
*/
_81
export function loadPasskeysFromLocalStorage(): PasskeyArgType[] {
_81
const passkeysStored = localStorage.getItem(STORAGE_PASSKEY_LIST_KEY)
_81
_81
const passkeyIds = passkeysStored ? JSON.parse(passkeysStored) : []
_81
_81
return passkeyIds
_81
}
_81
_81
/**
_81
* Get passkey object from local storage.
_81
* @param {string} passkeyRawId - Raw ID of the passkey.
_81
* @returns {PasskeyArgType} Passkey object.
_81
*/
_81
export function getPasskeyFromRawId(passkeyRawId: string): PasskeyArgType {
_81
const passkeys = loadPasskeysFromLocalStorage()
_81
_81
const passkey = passkeys.find(
_81
(passkey) => passkey.rawId === passkeyRawId
_81
)!
_81
_81
return passkey
_81
}

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.


_10
touch mintNFT.ts

Add the following code to the mintNFT.ts file:


_116
import type { PasskeyArgType } from '@safe-global/protocol-kit'
_116
import { Safe4337Pack } from '@safe-global/relay-kit'
_116
import { encodeFunctionData } from 'viem'
_116
_116
/**
_116
* Mint an NFT.
_116
* @param {PasskeyArgType} signer - Signer object with rawId and coordinates.
_116
* @param {string} safeAddress - Safe address.
_116
* @returns {Promise<void>}
_116
* @throws {Error} If the operation fails.
_116
*/
_116
export const mintNFT = async (passkey: PasskeyArgType, safeAddress: string) => {
_116
const runtimeConfig = useRuntimeConfig()
_116
_116
// 1) Initialize Safe4337Pack
_116
const paymasterOptions = {
_116
isSponsored: true,
_116
paymasterAddress: PAYMASTER_ADDRESS,
_116
paymasterUrl: PAYMASTER_URL + runtimeConfig.public.NUXT_PUBLIC_PIMLICO_API_KEY
_116
}
_116
_116
const safe4337Pack = await Safe4337Pack.init({
_116
provider: RPC_URL,
_116
signer: passkey,
_116
bundlerUrl: BUNDLER_URL + runtimeConfig.public.NUXT_PUBLIC_PIMLICO_API_KEY,
_116
paymasterOptions,
_116
options: {
_116
owners: [
_116
/* Other owners... */
_116
],
_116
threshold: 1
_116
}
_116
})
_116
_116
// 2) Create SafeOperation
_116
const mintNFTTransaction = {
_116
to: NFT_ADDRESS,
_116
data: encodeSafeMintData(safeAddress),
_116
value: '0'
_116
}
_116
_116
const safeOperation = await safe4337Pack.createTransaction({
_116
transactions: [mintNFTTransaction]
_116
})
_116
_116
// 3) Sign SafeOperation
_116
const signedSafeOperation = await safe4337Pack.signSafeOperation(
_116
safeOperation
_116
)
_116
_116
console.log('SafeOperation', signedSafeOperation)
_116
_116
// 4) Execute SafeOperation
_116
const userOperationHash = await safe4337Pack.executeTransaction({
_116
executable: signedSafeOperation
_116
})
_116
_116
return userOperationHash
_116
}
_116
_116
/**
_116
* Encodes the data for a safe mint operation.
_116
* @param to The address to mint the token to.
_116
* @param tokenId The ID of the token to mint.
_116
* @returns The encoded data for the safe mint operation.
_116
*/
_116
export function encodeSafeMintData(
_116
to: string,
_116
tokenId: bigint = getRandomUint256()
_116
): string {
_116
return encodeFunctionData({
_116
abi: [
_116
{
_116
constant: false,
_116
inputs: [
_116
{
_116
name: 'to',
_116
type: 'address'
_116
},
_116
{
_116
name: 'tokenId',
_116
type: 'uint256'
_116
}
_116
],
_116
name: 'safeMint',
_116
payable: false,
_116
stateMutability: 'nonpayable',
_116
type: 'function'
_116
}
_116
],
_116
functionName: 'safeMint',
_116
args: [to, tokenId]
_116
})
_116
}
_116
_116
/**
_116
* Generates a random 256-bit unsigned integer.
_116
*
_116
* @returns {bigint} A random 256-bit unsigned integer.
_116
*
_116
* This function uses the Web Crypto API's `crypto.getRandomValues()` method to generate
_116
* a uniformly distributed random value within the range of 256-bit unsigned integers
_116
* (from 0 to 2^256 - 1).
_116
*/
_116
function getRandomUint256(): bigint {
_116
const dest = new Uint8Array(32) // Create a typed array capable of storing 32 bytes or 256 bits
_116
_116
crypto.getRandomValues(dest) // Fill the typed array with cryptographically secure random values
_116
_116
let result = 0n
_116
for (let i = 0; i < dest.length; i++) {
_116
result |= BigInt(dest[i]) << BigInt(8 * i) // Combine individual bytes into one bigint
_116
}
_116
_116
return result
_116
}

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:


_10
cd ..
_10
mkdir stores
_10
cd stores
_10
touch safe.ts

Add the following code to the safe.ts file:


_32
import type { PasskeyArgType } from '@safe-global/protocol-kit'
_32
_32
export const useSafeStore = defineStore('safe', {
_32
state: () => ({
_32
passkeys: <PasskeyArgType[]>([]),
_32
selectedPasskey: <PasskeyArgType>({}),
_32
safeAddress: <string>(''),
_32
isSafeDeployed: <boolean>(false),
_32
isLoading: <boolean>(false),
_32
userOp: <string>('')
_32
}),
_32
actions: {
_32
setPasskeys(data: PasskeyArgType[]) {
_32
this.passkeys = data
_32
},
_32
setSelectedPasskey(data: PasskeyArgType) {
_32
this.selectedPasskey = data
_32
},
_32
setSafeAddress(data: string) {
_32
this.safeAddress = data
_32
},
_32
setIsSafeDeployed(data: boolean) {
_32
this.isSafeDeployed = data
_32
},
_32
setIsLoading(data: boolean) {
_32
this.isLoading = data
_32
},
_32
setUserOp(data: string) {
_32
this.userOp = data
_32
}
_32
}
_32
})

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.

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


_10
cd ../components
_10
touch LoginWithPasskey.vue

Add the following code to the LoginWithPasskey.vue file:


_67
<script setup lang="ts">
_67
import { useSafeStore } from "@/stores/safe";
_67
import { Safe4337Pack } from "@safe-global/relay-kit";
_67
_67
import type { PasskeyArgType } from "@safe-global/protocol-kit";
_67
_67
const store = useSafeStore();
_67
const runtimeConfig = useRuntimeConfig();
_67
_67
async function handleCreatePasskey() {
_67
const passkey = await createPasskey();
_67
_67
storePasskeyInLocalStorage(passkey);
_67
store.setSelectedPasskey(passkey);
_67
_67
await showSafeInfo(passkey);
_67
}
_67
_67
async function selectExistingPasskey() {
_67
const passkeys = loadPasskeysFromLocalStorage();
_67
_67
store.setPasskeys(passkeys);
_67
store.setSelectedPasskey(passkeys[0]);
_67
_67
await showSafeInfo(store.selectedPasskey);
_67
}
_67
_67
async function showSafeInfo(passkey: PasskeyArgType) {
_67
store.setIsLoading(true);
_67
const safe4337Pack = await Safe4337Pack.init({
_67
provider: RPC_URL,
_67
signer: passkey,
_67
bundlerUrl: BUNDLER_URL + runtimeConfig.public.NUXT_PUBLIC_PIMLICO_API_KEY,
_67
options: {
_67
owners: [],
_67
threshold: 1,
_67
},
_67
});
_67
store.setSafeAddress(await safe4337Pack.protocolKit.getAddress());
_67
store.setIsSafeDeployed(await safe4337Pack.protocolKit.isSafeDeployed());
_67
store.setIsLoading(false);
_67
}
_67
</script>
_67
_67
<template>
_67
<div
_67
v-if="Object.keys(store.selectedPasskey).length === 0"
_67
class="mt-20 bg-stone-800 p-8 rounded w-fit flex flex-col items-center"
_67
>
_67
<h1 class="text-4xl text-[#12FF80]">Use Safe Account via Passkeys</h1>
_67
<h2 class="my-12">Create a new Safe using Passkeys</h2>
_67
<UButton
_67
icon="material-symbols:fingerprint"
_67
block
_67
class="mb-8"
_67
variant="outline"
_67
@click="handleCreatePasskey"
_67
>
_67
Create a new passkey
_67
</UButton>
_67
<UDivider label="OR" :ui="{ border: { base: 'dark:border-gray-500' } }" />
_67
<h2 class="my-12">Connect existing Safe using an existing passkey</h2>
_67
<UButton icon="material-symbols:fingerprint" block @click="selectExistingPasskey"
_67
>Use an existing passkey</UButton
_67
>
_67
</div>
_67
</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:


_10
touch SafeAccountDetails.vue

Add the following code to the SafeAccountDetails.vue file:


_85
<script setup lang="ts">
_85
import { useSafeStore } from "@/stores/safe";
_85
_85
const store = useSafeStore();
_85
_85
async function handleMintNFT() {
_85
store.setIsLoading(true);
_85
_85
const userOp = await mintNFT(store.selectedPasskey, store.safeAddress!);
_85
_85
store.setIsLoading(false);
_85
store.setIsSafeDeployed(true);
_85
store.setUserOp(userOp);
_85
}
_85
_85
const DEFAULT_CHAR_DISPLAYED = 6;
_85
_85
function splitAddress(
_85
address: string,
_85
charDisplayed: number = DEFAULT_CHAR_DISPLAYED
_85
): string {
_85
const firstPart = address.slice(0, charDisplayed);
_85
const lastPart = address.slice(address.length - charDisplayed);
_85
_85
return `${firstPart}...${lastPart}`;
_85
}
_85
_85
const safeLink = `https://app.safe.global/home?safe=sep:${store.safeAddress}`;
_85
const jiffscanLink = `https://jiffyscan.xyz/userOpHash/${store.userOp}?network=${CHAIN_NAME}`;
_85
</script>
_85
_85
<template>
_85
<div
_85
v-if="Object.keys(store.selectedPasskey).length !== 0"
_85
class="mt-20 bg-stone-800 p-8 rounded w-fit flex flex-col items-center"
_85
>
_85
<h1 class="text-4xl text-[#12FF80]">Your Safe Accout</h1>
_85
<UIcon
_85
v-if="store.isLoading"
_85
name="line-md:loading-loop"
_85
class="mt-4 w-12 h-12 bg-[#12FF80]"
_85
/>
_85
<div v-if="!store.isLoading" class="flex flex-col items-center">
_85
<UButton
_85
variant="link"
_85
color="white"
_85
v-if="store.safeAddress"
_85
class="my-8"
_85
:to="safeLink"
_85
target="_blank"
_85
rel="noopener noreferrer"
_85
>
_85
<template #leading><img src="/safeLogo.png" class="w-8 h-8" /> </template
_85
>{{ splitAddress(store.safeAddress) }}
_85
<template #trailing><img src="/external-link.svg" class="w-5 h-5" /> </template>
_85
</UButton>
_85
<UBadge
_85
v-if="store.safeAddress && !store.isSafeDeployed"
_85
color="yellow"
_85
variant="solid"
_85
>Deployment pending
_85
</UBadge>
_85
<UButton
_85
variant="outline"
_85
v-if="store.safeAddress"
_85
icon="material-symbols:image-outline"
_85
class="mt-8 ml-2 mr-2"
_85
@click="handleMintNFT"
_85
>
_85
Mint NFT</UButton
_85
>
_85
<UButton
_85
variant="link"
_85
color="white"
_85
v-if="store.userOp"
_85
class="my-8"
_85
:to="jiffscanLink"
_85
target="_blank"
_85
rel="noopener noreferrer"
_85
>{{ store.userOp }}
_85
<template #trailing><img src="/external-link.svg" class="w-5 h-5" /> </template
_85
></UButton>
_85
</div>
_85
</div>
_85
</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:


_10
<template>
_10
<NuxtRouteAnnouncer />
_10
<NuxtLayout name="default">
_10
<LoginWithPasskey />
_10
<SafeAccountDetails />
_10
</NuxtLayout>
_10
</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 with this code:


_62
<script setup lang="ts">
_62
const links = [
_62
{
_62
label: "Docs",
_62
icon: "i-heroicons-book-open",
_62
to: "/getting-started",
_62
},
_62
{
_62
label: "Pro",
_62
icon: "i-heroicons-square-3-stack-3d",
_62
to: "/pro",
_62
},
_62
{
_62
label: "Releases",
_62
icon: "i-heroicons-rocket-launch",
_62
to: "/releases",
_62
},
_62
];
_62
</script>
_62
_62
<template>
_62
<div class="p-6">
_62
<header>
_62
<div class="flex items-center justify-between">
_62
<div class="flex items-center">
_62
<img src="/safe.svg" class="w-24 h-9" />
_62
</div>
_62
<div class="flex items-center">
_62
<UButton
_62
label="Button"
_62
variant="link"
_62
color="white"
_62
to="https://docs.safe.global/home/passkeys-tutorials/safe-passkeys-nuxt"
_62
target="_blank"
_62
rel="noopener noreferrer"
_62
>
_62
Read tutorial
_62
<template #trailing>
_62
<img src="/external-link.svg" class="w-5 h-5" />
_62
</template>
_62
</UButton>
_62
<UButton
_62
label="Button"
_62
variant="link"
_62
color="white"
_62
to="https://github.com/5afe/safe-passkeys-nuxt"
_62
target="_blank"
_62
rel="noopener noreferrer"
_62
>
_62
View on GitHub
_62
<template #trailing>
_62
<img src="/github.svg" class="w-7 h-7" />
_62
</template>
_62
</UButton>
_62
</div>
_62
</div>
_62
</header>
_62
<main class="flex justify-center">
_62
<slot />
_62
</main>
_62
</div>
_62
</template>

Finally, in the public folder, add three icons. You can also find them in the project's GitHub repository: safe.svg (opens in a new tab), github.svg (opens in a new tab), and external-link.svg (opens in a new tab).

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.

Was this page helpful?