How I integrated Privy's JavaScript SDK Core in a Vue 3 project

Inertia - May 30 - - Dev Community

Written by Kainoa from Inertia

Let me start this off by saying I love Privy (and no, they haven't paid me to say this) -- in my opinion, they're the best solution right now for embedded wallets and social login on-chain.

There's just one major problem, and this isn't with only Privy. This goes out to basically everyone making a product like this. It's way too focused on React!

Don't get me wrong, React can be cool. But it's not for everyone, and not everyone uses it. And this might be a hot take, but it's not even the best solution for highly interactive web apps, which is the thing Privy and its competitors are trying to target -- and yet they only make React libraries.


Let me set the scene...

Don't need my sob story? Skip to the good part.

Our team has spent months researching an embedded wallet provider that'll work for our use case. Literally nobody offers a headless library, let alone a WebComponent or Vue component. I'm about to give up, throw in the towel, and build a damn solution myself (not fun), switch to React (not fun) or try and jerry-rig a React library into our Vue app (not fun).

We then discover Privy. It seems to tick all the boxes on first glance -- reasonably laid out server-side authentication, lots of options for social login, based on Viem, and... of course, their primary SDK is for React. But then, on their docs, I see on the dropdown of SDKs this thing called @privy-io/js-sdk-core. Now, what could this be?

I click it and I'm met with two things, a big warning and a changelog. That's it. The warning in question?

⚠️ The Privy JS SDK is a low-level library and not intended for general consumption. Please do not attempt to use this library without first reaching out to the Privy team to discuss your project and which Privy SDK options may be better suited to it.

That's definitely not a good sign. The changelog is fairly sparse, and there's really not much of anything else useful. I click the link that takes me to NPM, and huzzah, there's some actual usage documentation there! It's not much, but it's something.

However, I do in fact heed their warning. I join their Slack group, and everyone there is super helpful off the bat (a breath of fresh air!) I end up talking with Max (shoutouts to you for being awesome btw), and he convinces me to give the React SDK a try, since the JS SDK Core (which I'll now be referring to as the JS SDK) isn't meant to be consumer facing yet. Fair.

Over the next month-ish, me and the one other developer I'm working with do end up getting working somewhat in Vue! This was done by using a library called Veaury to render the React component inside Vue. It was... very messy, to say the least. And while it did work to a certain extent, it caused a ton of bugs and issues, and got to the point where major functionality was broken, the web app was bogged down by a ton of unnecessary JS, and it was a major headache to work with.

I eventually decided that enough was enough, and I needed to use something that worked. Back to the JS SDK I go, throwing caution to the wind.

It took me two days to fully integrate it for all our needs.

Let me say that again. Two days.

I was trying to wrangle their React SDK for almost 2-3 weeks at this point.

Let me tell you how I did it.

But first, this would not be possible without the help of both Max and Josh from the Privy team. Thank you both so much!!

Enough yapping. Show me how you did it!

A bit of a disclaimer – this isn't particularly recommended by the Privy team, since the JS SDK Core isn't meant for general consumption, and the library often goes through breaking changes. Make sure to keep an eye on their changelog if you follow this guide!

I used Vue 3 for this, but it should be fairly comparable for any other JS framework (or even vanilla JS).

The Frontend

There's 4 parts of the frontend we're gonna touch on:

  • The main app: src/App.vue
  • The Privy helper: src/utils/privy.ts
  • The callback page: src/views/Callback.vue
  • The modals: src/views/components/Login.vue and src/views/components/2fa.vue

The goal? Get email/phone & social login working, and use ZeroDev for session signing (with the frontend as the "owner" and backend as the "agent"). I won't be going into a lot of detail on that part -- please read ZeroDev's docs & Privy's docs on this!

Dependencies

I'm going to assume you already have a Vue 3 app with Pinia set up.

In your frontend, run

bun i @privy-io/js-sdk-core @zerodev/ecdsa-validator @zerodev/permissions @zerodev/sdk permissionless viem
Enter fullscreen mode Exit fullscreen mode

The Privy helper

Let's get the meat and potatoes out of the way.

Since this is a helper utility file, pretty much everything in here is going to be exported to be used in other parts of the codebase.

Imports

import Privy, {
    getUserEmbeddedWallet,
    type PrivyEmbeddedWalletProvider,
    type OAuthProviderType,
} from "@privy-io/js-sdk-core";

import { signerToEcdsaValidator } from "@zerodev/ecdsa-validator";
import {
    serializePermissionAccount,
    toPermissionValidator,
} from "@zerodev/permissions";
import { toSudoPolicy } from "@zerodev/permissions/policies";
import { toECDSASigner } from "@zerodev/permissions/signers";
import {
    addressToEmptyAccount,
    createKernelAccount,
    createKernelAccountClient,
    createZeroDevPaymasterClient,
} from "@zerodev/sdk";

import {
    ENTRYPOINT_ADDRESS_V07,
    providerToSmartAccountSigner,
} from "permissionless";

import { http, type EIP1193Provider, createPublicClient } from "viem";
import { mainnet, sepolia } from "viem/chains";

import { ref } from "vue";
Enter fullscreen mode Exit fullscreen mode

Also, since the Privy SDK doesn't export their wallet type (why? 😭), let's copy it from from their index.d.ts and put it right below the imports:

export type PrivyEmbeddedWallet = {
    type: "wallet";
    address: `0x${string}`;
    verified_at: number;
    first_verified_at: number | null;
    latest_verified_at: number | null;
    chain_type: "ethereum";
    wallet_client: "unknown";
    chain_id?: string | undefined;
    wallet_client_type?: string | undefined;
    connector_type?: string | undefined;
};
Enter fullscreen mode Exit fullscreen mode

Why is the chain type "ethereum"? Well, it's "ethereum" for all EVM-compatible chains. So if you're using Avalanche, Arbitrum, etc it'll still be "ethereum". It'd only change to "solana" or "btc" if you're using Solana or Bitcoin respectively -- in that case, you should copy that type from @privy-io/js-sdk-core.

The Privy object

This should be pretty self-explanatory.

export const privy = new Privy({
    appId: import.meta.env.VITE_PRIVY_APP_ID,
    supportedChains: [mainnet, sepolia],
    storage: {
        get: (key: string) => localStorage.getItem(key),
        put: (key: string, value: string) => localStorage.setItem(key, value),
        del: (key: string) => localStorage.removeItem(key),
        getKeys: () => Object.keys(localStorage),
    },
});
Enter fullscreen mode Exit fullscreen mode

Did you know that if you're using Vite, by default, if you put VITE_ before an environment variable, it'll be loaded into the bundle? If you didn't... now you do.

But yeah, this is our main Privy object. If you've looked around the React SDK before, this... not really the same, but similar? Don't expect it to be 1:1.

Authentication

Below are two different kinds of functions: "handlers" and "verifiers". The handles wrap Privy's login-start logic while the verifiers handle the login-code-verification logic.

Let's start with email.

export async function handleEmailCode(email: string)
    await privy.auth.email.sendCode(email);
}
Enter fullscreen mode Exit fullscreen mode

This is the bare minimum you need, but we want a bit more than that. Let's add some basic validation:

if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return;
await privy.auth.email.sendCode(email);
Enter fullscreen mode Exit fullscreen mode

When I said "basic", I meant "basic". No chance in hell I'm using the "proper RegEx".

That's great, but how are we going to get to verifying the code sent to their email?

This is where your own implementation of UI comes in. I'm using a modal library called Vue Modal to handle modals for the UI, but you might have a different way of doing it, or you might not even want login in a modal. Maybe on its own page, or something else entirely. And that's fine! It's your app, your design, your rules.

However, going forward, I'm going to be going through this guide using Vue Modal. If you also want to use it, add these imports:

import { confirmModal, openModal } from "@kolirt/vue-modal";
import { defineAsyncComponent } from "vue";
Enter fullscreen mode Exit fullscreen mode

Now, back to handleEmailCode.

// ...verification...
await confirmModal(); // to close the current modal, that'll have inputs for email/phone and buttons for social providers
await privy.auth.email.sendCode(email);
await openModal(
    defineAsyncComponent(() => import("@/components/modals/2fa.vue")),
    {
        method: "email", // 2fa.vue can handle code inputs for email and phone
        email: email, // passing the email as a prop to 2fa.vue
    },
);
Enter fullscreen mode Exit fullscreen mode

So, the whole function looks like this:

export async function handleEmailCode(email: string) {
    if (!email) return;
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return;
    await confirmModal();
    await privy.auth.email.sendCode(email);
    await openModal(
        defineAsyncComponent(() => import("@/components/modals/2fa.vue")),
        {
            method: "email",
            email: email,
        },
    );
}
Enter fullscreen mode Exit fullscreen mode

Phew. Thankfully, the verification is a lot easier.

export async function verifyEmailCode(email: string, code: string) {
    const { user, is_new_user } = await privy.auth.email.loginWithCode(
        email,
        code,
    );
    return { user, isNewUser: is_new_user };
}
Enter fullscreen mode Exit fullscreen mode

The only thing that I think should be explained here is is_new_user versus isNewUser. The React SDK exposes if the user is a new user as isNewUser (and all those other properties) in camelCase. However, the JS SDK shows is_new_user as snake_case. For my convenience adapting the code from the React SDK to the JS SDK, I'm making the helper function behave a bit closer to the React SDK, at least semantically.

As for the useLogin hook in the React SDK, you get all of these:

useLogin({
    async onComplete(
        user,
        isNewUser,
        wasAlreadyAuthenticated,
        loginMethod,
    ) { /* ... */ });
Enter fullscreen mode Exit fullscreen mode

However, the JS SDK only gives you the first two. The other two should be fairly trivial to infer, however.

As for phone/SMS, it's basically the same thing as email, but with auth.phone instead of auth.email.

export async function handleSmsCode(phone: string) {
    if (!phone) return;
    await confirmModal();
    await privy.auth.phone.sendCode(phone);
    await openModal(
        defineAsyncComponent(() => import("@/components/modals/2fa.vue")),
        {
            method: "sms",
            phone: phone,
        },
    );
}

export async function verifySmsCode(phone: string, code: string) {
    const { user, is_new_user } = await privy.auth.phone.loginWithCode(
        phone,
        code,
    );
    return { user, isNewUser: is_new_user };
}
Enter fullscreen mode Exit fullscreen mode

Now for OAuth2. The idea is the same, but the execution is a bit different.

The OAuth2 flow for Privy is pretty similar to standard OAuth2 implementations:

Your frontend → Get OAuth2 URL for provider → OAuth2 provider's login page → auth.privy.io → Your frontend('s callback page) (with relevant information passed via URL params)

It basically just adds that middleman of auth.privy.io.

So, let's get that OAuth2 URL!

export async function handleOauthLogin(provider: OAuthProviderType) {
    const oauthUrl = await privy.auth.oauth.generateURL(
        provider,
        `${import.meta.env.VITE_FRONTEND_URL}/callback`, // in my case, the VITE_FRONTEND_URL is http://localhost:5173
    );
    window.location.href = oauthUrl.url;
}
Enter fullscreen mode Exit fullscreen mode

The nice part of doing this part ourselves is that we can add additional scopes to the OAuth2 URLs. For example, you might want to have some deeper Discord integration, like funneling your users to a support server.

// generate URL
if (provider === "discord") {
    oauthUrl.url = oauthUrl.url.replace("&scope=", "&scope=guilds.join+");
}
// redirect
Enter fullscreen mode Exit fullscreen mode

And the verifier:

export async function oauthCallback(
    code: string,
    state: string,
    provider: OAuthProviderType,
) {
    const { user, is_new_user } = await privy.auth.oauth.loginWithCode(
        code,
        state,
        provider,
    );
    return { user, isNewUser: is_new_user };
}
Enter fullscreen mode Exit fullscreen mode

Again, not much special going on there.

The Wallet Client

This was the part that tripped me up the most by far -- mostly due to lack of documentation.

Before we make our function, let's make set up a Vue ref for our wallet provider:

const embeddedWalletProvider = ref<PrivyEmbeddedWalletProvider | null>(null);
Enter fullscreen mode Exit fullscreen mode

Now, let's make the initWalletClient function.

export async function initWalletClient() {
}
Enter fullscreen mode Exit fullscreen mode

But what will this function even do? Well, a few things. But essentially, this makes a wallet embedded in an iframe (AN...EMBEDDED WALLET?!?! 🤯🤯🤯), and sets up a session signer for ZeroDev to use in order to give your session approval to the backend to perform actions on the wallet's behalf, all while still technically being non-custodial.

Poorly drawn comparison of traditional vs embedded wallets

First, let's see if we already have a Privy user, this'll try to get the data from localStorage (based on the storage {} key when making the privy object),

const privyUser = await privy.user.get();
Enter fullscreen mode Exit fullscreen mode

Let's add the handler and listener for those iframe <--> window events.

const messageHandler = privy.embeddedWallet.getMessageHandler();
window.addEventListener("message", (e: any) => {
    messageHandler(e.data);
});
Enter fullscreen mode Exit fullscreen mode

Why any when messageHandler's parameter for event is PrivyResponseEvent? Well, the JS SDK doesn't export PrivyResponseEvent, and trying to manually bring it into the helper doesn't even work properly since it's dependent on a lot of other non-exported types. So, any it is.

Now that we have the handler set up, let's actually get the embedded wallet, or create it if it doesn't exist!

let userEmbeddedWallet = getUserEmbeddedWallet(privyUser.user);
if (!userEmbeddedWallet) {
    // TODO: recovery
    const { provider } = await privy.embeddedWallet.create();
    embeddedWalletProvider.value = provider;
    userEmbeddedWallet = getUserEmbeddedWallet(privyUser.user);
} else {
    embeddedWalletProvider.value =
        await privy.embeddedWallet.getProvider(userEmbeddedWallet);
}
if (!userEmbeddedWallet) {
    throw new Error("No embedded wallet found");
}
Enter fullscreen mode Exit fullscreen mode

So what's going on here? First, we try to get the embedded wallet if it exists. If it doesn't, we make a new one.

The TODO for recovery is something I'll leave up to you, the reader, to implement. Privy allows a user to restore their wallet through a backup password or a backup to their Google Drive/iCloud. Pretty cool if you ask me.

If it does exist, we get the provider. There's one more check after that just in case the create() somehow didn't work.

The next part is for the session signer, copied almost entirely from the aforementioned ZeroDev docs & Privy docs, so I won't explain what goes on here, except for one bit.

const smartAccountSigner = await providerToSmartAccountSigner(
    embeddedWalletProvider.value as EIP1193Provider,
);
const publicClient = createPublicClient({
    transport: http(sepolia.rpcUrls.default.http[0]),
});
const ecdsaValidator = await signerToEcdsaValidator(publicClient, {
    signer: smartAccountSigner,
    entryPoint: ENTRYPOINT_ADDRESS_V07,
});

const keyResp = await api.signer.sessionKey.get();
if (keyResp.error) return;

const emptyAccount = addressToEmptyAccount(keyResp.data.sessionKeyAddress);
const emptySessionKeySigner = await toECDSASigner({ signer: emptyAccount });

const sudoPolicy = toSudoPolicy({});

const permissionPlugin = await toPermissionValidator(publicClient, {
    entryPoint: ENTRYPOINT_ADDRESS_V07,
    signer: emptySessionKeySigner,
    policies: [
        // ref: https://docs.zerodev.app/sdk/permissions/intro#policies
        sudoPolicy,
    ],
});

const account = await createKernelAccount(publicClient, {
    plugins: {
        sudo: ecdsaValidator,
        regular: permissionPlugin,
    },
    entryPoint: ENTRYPOINT_ADDRESS_V07,
});

const zerodevBundlerRpc = import.meta.env.VITE_ZERODEV_BUNDLER_RPC;
const walletClient = createKernelAccountClient({
    account,
    chain: sepolia,
    entryPoint: ENTRYPOINT_ADDRESS_V07,
    bundlerTransport: http(zerodevBundlerRpc),
    middleware: {
        sponsorUserOperation: async ({ userOperation }) => {
            const zerodevPaymaster = createZeroDevPaymasterClient({
                chain: sepolia,
                entryPoint: ENTRYPOINT_ADDRESS_V07,
                transport: http(zerodevBundlerRpc),
            });
            return zerodevPaymaster.sponsorUserOperation({
                userOperation,
                entryPoint: ENTRYPOINT_ADDRESS_V07,
            });
        },
    },
});
Enter fullscreen mode Exit fullscreen mode

Did you catch it? await api.signer.sessionKey.get(); -- that's a call to our own backend that gets the agent session key address from the ecdsaSigner created on the backend.

After that, just two more lines:

const approval = await serializePermissionAccount(account);
await api.signer.approve.post({ approval });
Enter fullscreen mode Exit fullscreen mode

which is where we send the approval over to the backend to be used for signing our requests.

Which leads me to...

The API helper

As you might've guessed, api is the helper I use for interacting with our backend. This is done via Eden Treaty, since our backend uses Elysia.

Let's take a detour to frontend/src/utils/api.ts:

import type { ElysiaApp } from "@/../backend/src/index";
import { treaty } from "@elysiajs/eden";

const token = localStorage.getItem("privy:token");
let headers = {};
if (token) {
    headers = {
        Authorization: `Bearer ${token}`,
    };
}
export const api = treaty<ElysiaApp>(import.meta.env.VITE_BACKEND_URL, {
    headers: headers,
});
Enter fullscreen mode Exit fullscreen mode

Unlike the React SDK, we need to set our headers manually -- so that's what I do right here. If you have a fetch helper or use tanstack's Vue Query, do the same.

And... that's it for the Privy helper!

The main app

Unlike the Privy helper, there's not that much going on here (here being App.vue).

The setup script

<script setup lang="ts">
// ... other imports
import { privy } from "@/utils/privy";
import { onMounted, ref } from "vue";

const iframeWallet = ref<HTMLIFrameElement>();
const iframeSrc = ref<string>();

onMounted(() => {
  if (!iframeWallet.value) return;
  iframeSrc.value = privy.embeddedWallet.getURL();
  // @ts-ignore
  privy.setMessagePoster(iframeWallet.value.contentWindow!);
});
</script>
Enter fullscreen mode Exit fullscreen mode

Yeah... that's it. Seriously. You might have some more logic in your setup script, but that's not relevant here.

What's happening here is that when the app is mounted, we set the iframe's source to be the URL (on Privy's end) from the embedded wallet, and then set the "poster" (the target for Privy on our website to talk to, that being the iframe).

Why do we @ts-ignore the setMessagePoster line? Well, if you don't, you'll get this:

Argument of type 'Window' is not assignable to parameter of type 'EmbeddedWalletMessagePoster'.
  Types of property 'postMessage' are incompatible.
Enter fullscreen mode Exit fullscreen mode

I'm sure that there's some types that could be imported or copied over from the SDK to satisfy TypeScript, but since I know it works (famous last words), it's far easier just to ignore it.

The template

Inside our <template>, we just need to put the iframe in:

  <iframe id="privy-embedded-wallet-iframe" ref="iframeWallet" :src="iframeSrc" style="display: none"></iframe>
Enter fullscreen mode Exit fullscreen mode

I personally think putting it outside the router view works better.

<template>
  <!-- navbar -->
  <iframe id="privy-embedded-wallet-iframe" ref="iframeWallet" :src="iframeSrc" style="display: none"></iframe>
  <router-view v-slot="{ Component }">
    <transition name="fade" mode="out-in">
      <component :is="Component" />
    </transition>
  </router-view>
</template>
Enter fullscreen mode Exit fullscreen mode

Congrats. You now have an embedded wallet!

The account store

For the next couple parts, I'm going to be referencing the account store. It's just a Pinia store set to localStorage, but you could handle this any other way -- a reactive state, localStorage/sessionStorage, etc.

Here's the store that I made in frontend/src/stores/account.ts, feel free to copy it, re-implement it, ignore it, whatever.

import type { PrivyEmbeddedWallet } from "@/utils/privy";
import type { KernelAccountClient } from "@zerodev/sdk";
import type { ENTRYPOINT_ADDRESS_V07 } from "permissionless";
import { defineStore } from "pinia";
import type { PublicActions, WalletClient } from "viem";
import { ref } from "vue";

export const useAccountStore = defineStore(
    "auth",
    () => {
        const walletClient = ref<
            | (WalletClient & PublicActions)
            | KernelAccountClient<typeof ENTRYPOINT_ADDRESS_V07>
        >();
        const embeddedWallet = ref<PrivyEmbeddedWallet>();
        const privy = ref<any>();
        const isNewUser = ref<boolean>(false);
        const isAuthenticated = ref<boolean>(false);
        const id = ref<string | undefined>();
        const token = ref<string | null>(null);
        return {
            walletClient,
            embeddedWallet,
            privy,
            isAuthenticated,
            isNewUser,
            id,
            token,
        };
    },
    {
        persist: true,
    },
);
Enter fullscreen mode Exit fullscreen mode

The callback page

Note that this callback page is only for OAuth2 -- email and phone verification is handled just through modals.

This is going to all be in frontend/src/views/Callback.vue, which should be set to load as /callback in your Vue router config.

Imports

We're going to need a couple things from our Privy helper, as well as a couple other things.

<script setup lang="ts">
import { useAccountStore } from "@/stores/account";
import {
    type PrivyEmbeddedWallet,
    initWalletClient,
    oauthCallback,
} from "@/utils/privy";
import type { OAuthProviderType } from "@privy-io/js-sdk-core";
import { useRouter } from "vue-router";
Enter fullscreen mode Exit fullscreen mode

With that out of the way, let's handle the params that auth.privy.io gives back to us. When handling OAuth2, Privy passes back the following URL parameters:

  • privy_oauth_code and privy_oauth_state: the OAUth2 verification code and state respectively. This is basically the same as any other OAuth2 implementation's code and state.
  • privy_oauth_provider: the provider that was used. This can be any of the options from OAuthProviderType, like google, discord, etc.

Let's get them from the URL:

const router = useRouter();
const route = router.currentRoute.value;

const code = route.query.privy_oauth_code as string;
const state = route.query.privy_oauth_state as string;
const provider = route.query.privy_oauth_provider as OAuthProviderType;

if (!code || !state || !provider) {
    router.push("/");
}
Enter fullscreen mode Exit fullscreen mode

that last push is just to send the user back to the homepage if the parameters weren't passed.

Remember how in the React SDK hook, we had wasAlreadyAuthenticated? Let's recreate that.

const accountStore = useAccountStore();
const wasAlreadyAuthenticated = accountStore.isAuthenticated === true;
Enter fullscreen mode Exit fullscreen mode

Now, let's do the actual callback logic. Because of how Vue handles async functions in setup scripts, the structure will look like this:

async function doCallback() {
}
doCallback()
Enter fullscreen mode Exit fullscreen mode

Inside of doCallback, we're going to get the user, store their authentication, initialize their wallet client, and send them back to the home page.

const { user, isNewUser } = await oauthCallback(code, state, provider);

const accessToken = await localStorage.getItem("privy:token");
accountStore.privy = user;
accountStore.isAuthenticated = true;
accountStore.token = accessToken;
if (isNewUser) accountStore.isNewUser = true;
accountStore.id = user.id.replace("did:privy:", "");
const wallet = user.linked_accounts.find(
    (account) => account.type === "wallet",
);
if (wallet) accountStore.embeddedWallet = wallet as PrivyEmbeddedWallet;

initWalletClient();
Enter fullscreen mode Exit fullscreen mode

That should all be fairly self explanitory, as we're just talking to the aformentioned account store and the functions from the Privy helper.

Now, let's redirect the user back to the main page:

router.push("/");
Enter fullscreen mode Exit fullscreen mode

Before that, you may want to have your server do something, especially if they're a new user. On our backend, we have a /api/signed-in route that adds the user to our database if they're new.

// ...acount store...
if (!wasAlreadyAuthenticated) {
    await api["signed-in"].post({ did: user.id });
}
Enter fullscreen mode Exit fullscreen mode

And that's it! For the template, it'll show up for a second or two so I just made it text that says "Loading...".

<template>
    <main>
        <h1>{{ $t("loading") }}</h1>
    </main>
</template>
Enter fullscreen mode Exit fullscreen mode

Modals

This is probably where your implementation will probably start to vary greatly from mine.

Login/Signup

Let's start with the login/signup modal (or page) -- how this is shown to your users is up to you.

The setup script should have the following:

<script setup lang="ts">
import {
    handleEmailCode,
    handleOauthLogin,
    handleSmsCode,
} from "@/utils/privy";
import { ref } from "vue";

const email = ref<string>();
const phone = ref<string>();
</script>
Enter fullscreen mode Exit fullscreen mode

and in the template, inputs for the email/phone that have v-model="email" @click.stop @keypress.enter="handleEmailCode(email)" and v-model="phone" @click.stop @keypress.enter="handleEmailCode(phone)" for phone numebrs and emails respectively, and buttons that have @click="handleOauthLogin('provider')" -- for example, for a Discord login button, I have

<div class="socialButton" @click="handleOauthLogin('discord')" :aria-label="$t('auth.discord')">
     <PhDiscordLogo size="3em" />
</div>
Enter fullscreen mode Exit fullscreen mode

2FA

Here, I'm using a library called vue3-otp-input to handle the actual inputs for the 2FA code, but you can use a standard <input /> or anything else of your choosing.

My imports consist of:

<script setup lang="ts">
import { useAccountStore } from "@/stores/account";
import {
    type PrivyEmbeddedWallet,
    initWalletClient,
    verifyEmailCode,
    verifySmsCode,
} from "@/utils/privy";
import { privy } from "@/utils/privy";
import { confirmModal } from "@kolirt/vue-modal";
import { ref } from "vue";
import { useRouter } from "vue-router";
import VOtpInput from "vue3-otp-input";
Enter fullscreen mode Exit fullscreen mode

There's a bit more logic to this one.

I define the props as such:

const props = defineProps({
    method: {
        type: String,
        default: "email",
        validator(value: string): boolean {
            return ["email", "sms"].includes(value);
        },
    },
    email: {
        type: String,
        default: "",
    },
    phone: {
        type: String,
        default: "",
    },
});
Enter fullscreen mode Exit fullscreen mode

This allows the modal to be used for 2FA for both email and SMS.

The rest of this should be self-explanitory, it's very similar to how the Callback.vue page is set up.

const router = useRouter();
const accountStore = useAccountStore();
const otpInput = ref<InstanceType<typeof VOtpInput> | null>(null);
const bindModal = ref("");

async function handleOnComplete(value: string) {
    try {
        const { user, isNewUser } =
            props.method === "email"
                ? await verifyEmailCode(props.email, value)
                : await verifySmsCode(props.phone, value);
        const wasAlreadyAuthenticated = accountStore.isAuthenticated === true;

        const accessToken = await localStorage.getItem("privy:token");
        accountStore.privy = user;
        accountStore.isAuthenticated = true;
        accountStore.token = accessToken;
        if (isNewUser) accountStore.isNewUser = true;
        accountStore.id = user.id.replace("did:privy:", "");
        const wallet = user.linked_accounts.find(
            (account) => account.type === "wallet",
        );
        if (wallet) accountStore.embeddedWallet = wallet as PrivyEmbeddedWallet;
        initWalletClient();

        if (!wasAlreadyAuthenticated) {
            await api["signed-in"].post({ did: user.id });
        }
        router.push("/");

        await confirmModal();
        router.push("/");
    } catch (error) {

        // Means that the 2FA code was wrong
        const inputs = document.querySelectorAll(".otp-input");
        for (const input of inputs) {
            input.classList.add("is-wrong");
            setTimeout(() => {
                input.classList.remove("is-wrong");
                otpInput.value?.clearInput();
            }, 500);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And the template containing:

<v-otp-input ref="otpInput" input-classes="otp-input" :num-inputs="6" v-model:value="bindModal" :should-auto-focus="true" :should-focus-order="true" @on-complete="handleOnComplete" />
Enter fullscreen mode Exit fullscreen mode

And... that's it for the frontend! You're pretty much there at this point.

The backend

A lot of what happens on the backend is fairly standard to how you'd implement things for the React SDK, but I'll quickly touch on a couple points:

JWT validation

I have this function that I protect all authenticated routes with:

const privy = new PrivyClient(
    Bun.env.PRIVY_APP_ID,
    Bun.env.PRIVY_APP_SECRET,
);

async function doAuth(bearer: string) {
    const verifiedClaims = await privy.verifyAuthToken(bearer);
    if (!verifiedClaims.userId) {
        return {
            status: 403,
        };
    }
    return {
        userId: verifiedClaims.userId,
        status: 200,
    };
}
Enter fullscreen mode Exit fullscreen mode

Session keys

The two signer-related routes made are as follows:

GET /signer/sessionKey

app.get(
    "/signer/sessionKey",
    async ({ bearer, error }) => {
        const auth = await doAuth(bearer as string);
        if (auth.status !== 200) return error(auth.status);
        return {
            sessionKeyAddress: sessionKeyAddress,
        };
    },
    {
        detail: {
            description: "Get the agent session key address",
            tags: ["signer"],
        },
    },
)
Enter fullscreen mode Exit fullscreen mode

Where the sessionKeyAddress is grabbed from the ecdsaSigner:

const ecdsaSigner = toECDSASigner({ signer: remoteSigner });
const sessionKeyAddress = ecdsaSigner.account.address;
Enter fullscreen mode Exit fullscreen mode

But that's not for this tutorial, if you want more information on that, please read the ZeroDev and Privy documentation I previously linked.

POST /signer/approve

app.post(
    "/signer/approve",
    async ({ body, bearer, error }) => {
        const auth = await doAuth(bearer as string);
        if (auth.status !== 200) return error(auth.status);
        await db.create<Approval>("approvals", {
            approval: body.approval,
            userId: auth.userId,
        });
        return { ok: true };
    },
    {
        body: t.Object({
            approval: t.String({
                title: "Approval",
                description: "Approval from embedded wallet",
            }),
        }),
        detail: {
            description:
                "Approve a session key sent from the client's embedded wallet",
            tags: ["signer"],
        },
    },
)

Enter fullscreen mode Exit fullscreen mode

We're done! Yay!

Phew, you made it to the end! Hopefully you got a good idea of how to implement the Privy JS SDK. Hope you had fun and thanks for reading! :D

If you make an open implementation/library, feel free to tag me on Github (@thatonecalculator) or send me an email kainoa@inertia.social -- I'd love to see what y'all make!

.