โšกSupabase

A guide to allowing users to login with their wallet to your Supabase-powered app!

Live Demo

Checkout a live demo of a Supabase + Picket powered app

Overview

Supabase is an open-source firebase alternative. Supabase's JWT-based authentication makes it easy to plug in additional authentication providers, like Picket. Picket's Supabase integration gives you the best of web2 and web3. Picket allows your users to log in with their wallet, but still leverage Supabase's awesome data management and security features.

Use Cases for Supabase + Picket

  • Account linking. Allow users to associate their wallet address(es) with their existing web2 account in your app

  • Leverage Supabase's awesome libraries and ecosystem while still enabling wallet login

  • Store app-specific data, like user preferences, about your user's wallet adress off-chain

  • Cache on-chain data to improve your DApp's performance

This guide will walk you through how to add Picket to a new Supabase app. By the end of this guide your users will be able to log into your Supabase app via their wallet of choice!

Getting Started

Requirements

Setup Picket

First, we'll create a new project in our Picket dashboard.

1. Go to your Picket Dashboard

Picket Dashboard

2. Create a new Project

You'll see the Create New Project button at the top of the Projects section of your Picket dashboard. Alternatively, you can re-use an existing project. Feel free to edit the project to give it a memorable name.

We're done for now! We'll revisit this project when we are setting up environment variables in our app.

Setup Supabase

Next, we'll create and initialize an equivalent project in Supabase.

1. Create a New Project

From your Supabase dashboard, click New project.

Enter a Name for your Supabase project.

Enter a secure Database Password.

Click Create new project.

2. Create a New Table

From the sidebar menu in the Supabase dashboard, click Table editor, then New table.

Enter todos as the Name field.

Select Enable Row Level Security (RLS).

Create four columns:

  • name as text

  • wallet_address as text

  • completed as bool with the default value false

  • created_at as timestamptz with a default value of now()

Click Save to create the new table.

3. Setup Row Level Security (RLS)

Now we want to make sure that only the todos owner, the user's wallet_address, can access their todos. The key component of the this RLS policy is the expression

((auth.jwt() ->> 'walletAddress'::text) = wallet_address)

This expression checks that the wallet address in the requesting JWT access token is the same as the wallet_address in the todos table.

Create a Next.js App

Now, let's start building!

Create a new Typescript Next.js app

npx create-next-app@latest --typescript

Create a .env.local file and enter the following values

  • NEXT_PUBLIC_PICKET_PUBLISHABLE_KEY => Copy the publishable key from the Picket project you created in the previous step

  • PICKET_PROJECT_SECRET_KEY => Copy the secret key from the Picket project you created in the previous step

  • NEXT_PUBLIC_SUPABASE_URL => You can find this URL under "Settings > API" in your Supabase project

  • NEXT_PUBLIC_SUPABASE_ANON_KEY => You can find this project API key under "Settings > API" in your Supabase project

  • SUAPBASE_JWT_SECRET=> You can find this secret under "Settings > API" in your Supabase project

NEXT_PUBLIC_PICKET_PUBLISHABLE_KEY="YOUR_PICKET_PUBLISHABLE_KEY"
PICKET_PROJECT_SECRET_KEY="YOUR_PICKET_PROJECT_SECRET_KEY"
NEXT_PUBLIC_SUPABASE_URL="YOUR_SUPABASE_URL"
NEXT_PUBLIC_SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY"
SUPABASE_JWT_SECRET="YOUR_SUPABASE_JWT_SECRET"

Setup Picket for Wallet Login

Picket React Quick Start Guide

For more information on how to setup Picket in your React app, checkout the getting started guide

After initializing our app, we can setup Picket.

Install the Picket React and Node libraries

npm i @picketapi/picket-react @picketapi/picket-node

Update pages/_app.tsx to setup the PicketProvider

import "../styles/globals.css";
import type { AppProps } from "next/app";

import { PicketProvider } from "@picketapi/picket-react";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <PicketProvider apiKey={process.env.NEXT_PUBLIC_PICKET_PUBLISHABLE_KEY!}>
      <Component {...pageProps} />
    </PicketProvider>
  );
}

Update pages/index.tsx to let users log in and out with their wallet

import { GetServerSideProps } from "next";
import { useRouter } from "next/router";
import { useCallback } from "react";

import styles from "../styles/Home.module.css";

import { usePicket } from "@picketapi/picket-react";
import { cookieName } from "../utils/supabase";

type Props = {
  loggedIn: boolean;
};

export default function Home(props: Props) {
  const { loggedIn } = props;
  const { login, logout, authState } = usePicket();
  const router = useRouter();

  const handleLogin = useCallback(async () => {
    let auth = authState;
    // no need to re-login if they've already connected with Picket
    if (!auth) {
      // login with Picket
      auth = await login();
    }

    // login failed
    if (!auth) return;

    // create a corresponding supabase access token
    await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        accessToken: auth.accessToken,
      }),
    });
    // redirect to their todos page
    router.push("/todos");
  }, [authState, login, router]);

  const handleLogout = useCallback(async () => {
    // clear both picket and supabase session
    await logout();
    await fetch("/api/logout", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
    });
    // refresh the page
    router.push("/");
  }, [logout, router]);

  return (
    <div className={styles.container}>
      <main className={styles.main}>
        {loggedIn ? (
          <button onClick={handleLogout}>Log Out to Switch Wallets</button>
        ) : (
          <button onClick={handleLogin}>Log In with Your Wallet</button>
        )}
      </main>
    </div>
  );
}

export const getServerSideProps: GetServerSideProps<Props> = async ({
  req,
}) => {
  // get supabase token server-side
  const accessToken = req.cookies[cookieName];

  if (!accessToken) {
    return {
      props: {
        loggedIn: false,
      },
    };
  }

  return {
    props: {
      loggedIn: true,
    },
  };
};

Issue a Supabase JWT on Login

Great, now we have setup a typical Picket React app. Next, we need to implement the log in/out API routes to allow users to securely query our Supabase project.

First, install dependencies

npm install @supabase/supabase-js jsonwebtoken cookie js-cookie

Create a utility function to create a Supabase client utils/supabase.ts

import { createClient, SupabaseClientOptions } from "@supabase/supabase-js";

export const cookieName = "sb-access-token";

const getSupabase = (accessToken: string) => {
  const options: SupabaseClientOptions<"public"> = {};

  if (accessToken) {
    options.global = {
      headers: {
        // This gives Supabase information about the user (wallet) making the request
        Authorization: `Bearer ${accessToken}`,
      },
    };
  }

  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    options
  );

  return supabase;
};

export { getSupabase };

Create new api route pages/api/login.ts. This route validates the Picket access token then issues another equivalent Supabase access token for us to use with the Supabase client.

import type { NextApiRequest, NextApiResponse } from "next";
import jwt from "jsonwebtoken";
import cookie from "cookie";
import Picket from "@picketapi/picket-node";

import { cookieName } from "../../utils/supabase";

// create picket node client with your picket secret api key
const picket = new Picket(process.env.PICKET_PROJECT_SECRET_KEY!);

const expToExpiresIn = (exp: number) => exp - Math.floor(Date.now() / 1000);

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { accessToken } = req.body;
  // omit expiration time,.it will conflict with jwt.sign
  const { exp, ...payload } = await picket.validate(accessToken);
  const expiresIn = expToExpiresIn(exp);

  const supabaseJWT = jwt.sign(
    {
      ...payload,
    },
    process.env.SUPABASE_JWT_SECRET!,
    {
      expiresIn,
    }
  );

  // Set a new cookie with the name
  res.setHeader(
    "Set-Cookie",
    cookie.serialize(cookieName, supabaseJWT, {
      path: "/",
      secure: process.env.NODE_ENV !== "development",
      // allow the cookie to be accessed client-side
      httpOnly: false,
      sameSite: "strict",
      maxAge: expiresIn,
    })
  );
  res.status(200).json({});
}

And now create an equivalent logout api route /pages/api/logout.ts to delete the Supabase access token cookie.

import type { NextApiRequest, NextApiResponse } from "next";
import cookie from "cookie";

import { cookieName } from "../../utils/supabase";

export default async function handler(
  _req: NextApiRequest,
  res: NextApiResponse
) {
  // Clear the supabase cookie
  res.setHeader(
    "Set-Cookie",
    cookie.serialize(cookieName, "", {
      path: "/",
      maxAge: -1,
    })
  );

  res.status(200).json({});
}

We can now login and logout to the app with our wallet!

Interacting with Data in Supabase

Now that we can login to the app, it's time to start interacting with Supabase. Let's make a todo list page for authenticated users.

Create a new file pages/todos.tsx

import { GetServerSideProps } from "next";
import Head from "next/head";
import Link from "next/link";
import { useState, useMemo } from "react";
import jwt from "jsonwebtoken";
import Cookies from "js-cookie";

import styles from "../styles/Home.module.css";

import { getSupabase, cookieName } from "../utils/supabase";

type Todo = {
  name: string;
  completed: boolean;
};

type Props = {
  walletAddress: string;
  todos: Todo[];
};

const displayWalletAddress = (walletAddress: string) =>
  `${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}`;

export default function Todos(props: Props) {
  const { walletAddress } = props;
  const [todos, setTodos] = useState(props.todos);

  // avoid re-creating supabase client every render
  const supabase = useMemo(() => {
    const accessToken = Cookies.get(cookieName);
    return getSupabase(accessToken || "");
  }, []);

  return (
    <div className={styles.container}>
      <Head>
        <title>Picket ๐Ÿ’œ Supabase</title>
      </Head>

      <main className={styles.main}>
        <h1 className={styles.title}>Your Personal Todo List</h1>
        <div
          style={{
            maxWidth: "600px",
            textAlign: "left",
            fontSize: "1.125rem",
            margin: "36px 0 24px 0",
          }}
        >
          <p>Welcome {displayWalletAddress(walletAddress)},</p>
          <p>
            Your todo list is stored in Supabase and are only accessible to you
            and your wallet address. Supabase + Picket makes it easy to build
            scalable, hybrid web2 and web3 apps. Use Supabase to store
            non-critical or private data off-chain like user app preferences or
            todo lists.
          </p>
        </div>
        <div
          style={{
            textAlign: "left",
            fontSize: "1.125rem",
          }}
        >
          <h2>Todo List</h2>
          {todos.map((todo) => (
            <div
              key={todo.name}
              style={{
                margin: "8px 0",
                display: "flex",
                alignItems: "center",
              }}
            >
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={async () => {
                  await supabase.from("todos").upsert({
                    wallet_address: walletAddress,
                    name: todo.name,
                    completed: !todo.completed,
                  });
                  setTodos((todos) =>
                    todos.map((t) =>
                      t.name === todo.name
                        ? { ...t, completed: !t.completed }
                        : t
                    )
                  );
                }}
              />
              <span
                style={{
                  margin: "0 0 0 8px",
                }}
              >
                {todo.name}
              </span>
            </div>
          ))}
          <div
            style={{
              margin: "24px 0",
            }}
          >
            <Link
              href={"/"}
              style={{
                textDecoration: "underline",
              }}
            >
              Go back home &rarr;
            </Link>
          </div>
        </div>
      </main>
    </div>
  );
}

export const getServerSideProps: GetServerSideProps<Props> = async ({
  req,
}) => {
  // example of fetching data server-side
  const accessToken = req.cookies[cookieName];

  // require authentication
  if (!accessToken) {
    return {
      redirect: {
        destination: "/",
      },
      props: {
        walletAddress: "",
        todos: [],
      },
    };
  }

  // check if logged in user has completed the tutorial
  const supabase = getSupabase(accessToken);
  const { walletAddress } = jwt.decode(accessToken) as {
    walletAddress: string;
  };

  // get todos for the users
  // if none exist, create the default todos
  let { data } = await supabase.from("todos").select("*");

  if (!data || data.length === 0) {
    let error = null;
    ({ data, error } = await supabase
      .from("todos")
      .insert([
        {
          wallet_address: walletAddress,
          name: "Complete the Picket + Supabase Tutorial",
          completed: true,
        },
        {
          wallet_address: walletAddress,
          name: "Create a Picket Account (https://picketapi.com/)",
          completed: false,
        },
        {
          wallet_address: walletAddress,
          name: "Read the Picket Docs (https://docs.picketapi.com/)",
          completed: false,
        },
        {
          wallet_address: walletAddress,
          name: "Build an Awesome Web3 Experience",
          completed: false,
        },
      ])
      .select("*"));

    if (error) {
      // log error and redirect home
      console.error(error);
      return {
        redirect: {
          destination: "/",
        },
        props: {
          walletAddress: "",
          todos: [],
        },
      };
    }
  }

  return {
    props: {
      walletAddress,
      todos: data as Todo[],
    },
  };
};

This is a long file, but don't be intimidated. The page is actually straightforward. It

  1. Verifies server-side that the user is authenticated and if they are not redirects them to the homepage

  2. Checks to see if they already have todos . If so, it returns them. If not, it initializes them for the users

  3. We render the todos and when the user selects or deselects a todo, we update the data in the database

Try it Out!

And that's it. If you haven't already, run your app to test it out yourself

# start the app
npm run dev
# open http://localhost:3000

[Advanced Guide] Supporting Multiple Login Methods

At this point, you have a Supabase-powered app that allows users to log in with their wallets. This works well for apps that only allow users to connect and login with their wallet. But what if you want allow users to log in with their wallet or traditional authentication mechanisms like email, phone, or OAuth 2.0.

This guide walks you through how to integrate wallet login into Supabase's native auth.users table. By leveraging Supabase's native auth table, users can log in via their preferred authentication method. Downstream of login, you can treat all users the same regardless of their login method by referencing their unique user id from the auth.users table.

Tailor to Your App's Requirements

It's important to note that this guide is a template for using Picket to enable wallet login with Supabase's native auth.users table. It is not meant to be prescriptive. Treat this guide as a starting point for your application and customize it to meet your applications specific requirements. If you have any questions, don't hesitate to reach out to team@picketapi.com

Update Login Endpoint

First, we are going to update the login endpoint pages/api/login.ts to fetch the corresponding Supabase user for the wallet if the user exists. If the user doesn't exist, we are going to create a new user and save the user's wallet information to the app_metadata field.

import type { NextApiRequest, NextApiResponse } from "next";
import jwt from "jsonwebtoken";
import cookie from "cookie";
import Picket, { AuthenticatedUser } from "@picketapi/picket-node";

import { cookieName, getSupabaseAdminClient } from "../../utils/supabase";

// create picket node client with your picket secret api key
const picket = new Picket(process.env.PICKET_PROJECT_SECRET_KEY!);

const expToExpiresIn = (exp: number) => exp - Math.floor(Date.now() / 1000);

const getOrCreateUser = async (payload: AuthenticatedUser) => {
  const supabase = getSupabaseAdminClient();

  // first we get the user by wallet address from user_wallet table
  let { data: userWallet } = await supabase
    .from("user_wallet")
    .select("user_id")
    .eq("wallet_address", payload.walletAddress)
    .eq("chain", payload.chain)
    .maybeSingle();

  const userID = userWallet?.user_id;
  if (!userID) {
    let { data } = await supabase.auth.admin.createUser({
      // @ts-ignore
      email: payload.email,
      app_metadata: {
        provider: "picket",
        providers: ["picket"],
        // store authorization info in app_metadata
        // because it cannot be modified by users
        walletAddress: payload.walletAddress,
        chain: payload.chain,
      },
      user_metadata: {
        ...payload,
      },
    });
    return data?.user;
  }

  let { data } = await supabase.auth.admin.getUserById(userID);
  return data?.user;
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { accessToken } = req.body;
  // omit expiration time,.it will conflict with jwt.sign
  const { exp, ...payload } = await picket.validate(accessToken);
  const expiresIn = expToExpiresIn(exp);
  const user = await getOrCreateUser(payload);

  const supabaseJWT = jwt.sign(
    {
      ...user,
    },
    process.env.SUPABASE_JWT_SECRET!,
    {
      expiresIn,
    }
  );

  // Set a new cookie with the name
  res.setHeader(
    "Set-Cookie",
    cookie.serialize(cookieName, supabaseJWT, {
      path: "/",
      secure: process.env.NODE_ENV !== "development",
      // allow the cookie to be accessed client-side
      httpOnly: false,
      sameSite: "strict",
      maxAge: expiresIn,
    })
  );
  res.status(200).json({});
}

The key changes are in the new getOrCreateUser function. The update logic is

  1. For the given wallet, check to see if a user already exists. We use a new table user_wallet to lookup a user ID for a wallet address. We will create this table in the next step.

  2. If the user doesn't exist, create a new user via the admin auth API. This requires a Supabase client that is created with your project's secret service role key

  3. If the user does exist, fetch the user via their user ID. This requires a Supabase client that is created with your project's secret service role key

  4. Create a Supabase JWT from the user info. This is the same JWT structure returned by other native Supabase login mechanism.

We store the user's wallet information in the app_metadata field, so we can retrieve it client-side from the the JWT or database-side in RLS policies.

We use the app_metadata field instead of the user_metadata because user can update user_metadata. We do not want users to change their wallet address without verifying ownership, so we use the app_metadata field which is only modifiable by an admin role.

Create a Table for Mapping User ID to Wallet Address

There is no native Supabase SDK for filtering users based on app_metadata. To do this, we create a lookup table user_wallet that maps wallet addresses to user IDs.

From the sidebar menu in the Supabase dashboard, click Table editor, then New table.

Enter user_wallet as the Name field.

Select Enable Row Level Security (RLS).

Create two columns:

  • user_id as uuid with foreign key relationship to auth.user(id). Set this as the primary key.

  • wallet_address as text

Click Save to create the new table.

Alternative Approaches

We use a lookup table user_wallet to fetch a user_id for a given wallet address.

An alternative approach is to query auth.users directly. At the time of writing this, there is no native Supabase SDK method for query a user based on their app_metadata. The only way to query the raw_app_metadata field in auth.users is to connect and query Postgres directly or create a custom RPC.

Any of these approaches work. Choose the method that best meets your app's requirements.

Trigger Inserts on New User Creation

Now we have lookup a table, but how do we populate it? The recommended approach is to create an on insert trigger in Postgres. This trigger will automatically updates the mapping between user_id and wallet_address any time a new user is created in the auth.users table.

-- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth.
-- See https://supabase.com/docs/guides/auth/managing-user-data#using-triggers for more details.
create function public.handle_new_user()
returns trigger as $$
begin
  insert into public.user_wallet (user_id, wallet_address)
  values (new.id, new.raw_app_meta_data->>'walletAddress');
  return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

RLS

By leveraging Supabase's native auth.users table, we can write RLS policies based off user ID rather than the wallet address RLS policy we defined above. This keeps our logic consistent regardless of the user's login mechanism.

However, if we still want to restrict usage based off wallet address, then we can easily do so with the following RLS expression

auth.jwt()->'app_metadata'->>'walletAddress' = wallet_address

Build Anything

You now have the foundation to build any application that supports wallet login along with any other Supabase native auth mechanism.

It's important to note that this guide is a template to help you get started. It is not meant to be prescriptive. Use the concepts in this guide and adjust the implementation to meet your applications specific requirements.

If you have any questions, don't hesitate to reach out to team@picketapi.com. We're here to help!

Last updated