DEV Community

Michael Pierce
Michael Pierce

Posted on • Updated on

Supabase & Metamask Signed Authentication (Web3)

Supabase is a great tool. But currently lacks the ability to natively use a Web3 Provider to authenticate. This guide aims to provide a walkthrough so you'll be able to issue JSON web tokens to users who sign-in with their Ethereum Wallet.

What you'll need:

  1. Supabase account
    1. JWT Key and Service Key
  2. Supabase public.users table
  3. A Client able to interact with Wallet Provider
    1. ethers.js and MetaMask
  4. Serverless Function Endpoints:

      /api/nonce
      /api/login
      /api/write
    

This walkthrough assumes your user has already connected their wallet. If you need help with that, check out the ethers.js documentation.

Supabase public.users Table

Before we can get into the code, we'll need to set up a new table in our Supabase project. It's basically a copy of auth.users. Supabase's private, built-in Auth table. This is necessary because Supabase does not allow you to query their Auth table by email or, in our case, Ethereum address (address).

But why do we need to query it? Well, in order to manually sign a JWT auth token, we need the user's id as it's stored in the auth.users table. So we'll need to store a copy ourselves. We'll also store other user data as it comes up, like a profile picture or email address. Another value is the user's login nonce. Which we'll get into in the next section.

Below you'll see what my public.users table looks like with mock data:

 raw `public.users` endraw  table with mock data

Set, Insert, then Return Nonce

Once the tables are setup and the user has connected their wallet, the first thing we'll do behind the scenes in our client app code is make a POST request to the /api/nonce endpoint. In it, we'll include the just-connected wallet address.

What's a nonce? It's a one-time use number we'll include in our /api/login request to add another layer of security. This is where the /api/nonce endpoint comes into play. And it's why we hit it first. So, once the server receives the request, it'll generate a random nonce, insert it to the proper public.users database row, then send the nonce back to the client. Once we've done all that, then we'll have them sign message with this nonce. Here's an idea what that endpoint would look like:

// /api/nonce

const { address } = req.body
const nonce = Math.floor(Math.random() * 1000000)

await database
  .from(SUPABASE_TABLE_USER)
  .update({ auth: {
              genNonce: nonce,
              lastAuth: new Date().toISOString(),
              lastAuthStatus: "pending"
          }}) 
  .eq('address', address)

return res.status(200).json({ nonce })
Enter fullscreen mode Exit fullscreen mode

Use the Nonce, Sign a Message

Then, once the client has received the nonce, we'll then automatically prompt the user to sign() a message in their wallet. This message should include the address and nonce, but can include whatever you want. It's also usually a good idea from a UX perspective to inform the user this is off-chain and costs no gas. Not everyone is familiar with this concept.

If you've used Opensea or most other Web3 apps, I'm sure you've seen this:

Example Sign()

On the client, the code will look something like this:

  // client code

  // prompt user to sign message in wallet
  const msg = await state.activeProvider
    .send("personal_sign", [
      ethers.utils.hexlify(ethers.utils.toUtf8Bytes(message)), state.address.toLowerCase()
    ]);

  // post sign message to api/verify with nonce and address
  const verifyRequest = await postData(
    `${state.config.API_URL}/api/login`, 
    { signed: msg, 
      nonce: nonceRequest.nonce,
      address: state.address 
    }
  )

Enter fullscreen mode Exit fullscreen mode

Then Login

Now for the fun part. After the user has signed, we hit the /api/login endpoint with their signed message and nonce. Then we'll see if the user has an id yet in the public.users table. If not, we'll invoke auth.admin.createUser() to create a user in the auth.users table, which'll then return the id. Once we have the id, we'll insert it into our public.users table, along with any other information we need. In the future, I can query to get an address's id. I know it's a little awkward, but it is a workaround. Check out this code fragment from the server:

  // api/login (only run this code on server)

  /*
    1. verify the signed message matches the requested address
    2. select * from public.user table where address matches
    3. verify the nonce included in the request matches what's 
    already in public.users table for that address
    4. if there's no public.users.id for that address, then you
    need to create a user in the auth.users table
  */

  const { data: user, error } = await supabase.auth.admin.createUser({
    email: `user@email.com`,
    user_metadata: { address: address }
  })

  // 5. insert response into public.users table with id

  await supabase
    .from(SUPABASE_TABLE_USERS)
    .update({ auth: {
        genNonce: newNonce, // update the nonce, so it can't be reused
        lastAuth: new Date().toISOString(),
        lastAuthStatus: "success"
      },
     id: user.id, // same uuid as auth.users table
    })
    .eq('address', address) // primary key

  // 6. lastly, we sign the token, then return it to client

Enter fullscreen mode Exit fullscreen mode

Next, we need to sign a token with our Supabase JWT then return it to the client. This will allow us to create an RLS Policy so only a particular address can insert data to either the public.users table, as an updated profile, for example. Or to another table, which contains off-chain app data that only certain token holders can upload. Whatever you'd like.

It also introduces a concept of "authenticated" to your client app, beyond just the standard wallet provider connection. Another nice thing is the user won't have to "sign" a message everytime they enter your application. They'll only need to do this again after their JWT expired. Here's an example JWT creation:

// /api/login (6.)

const token = jwt.sign({
  address: address, // this will be read by RLS policy
  sub: user.id,
  aud: 'authenticated'
}, JWT, { expiresIn: 60*2 } )

res.status(200).send(token)

Enter fullscreen mode Exit fullscreen mode

Now that the JWT token is on the client side, we'll want to set up a Supabase RLS Policy for the public.users table, or any other table we want authenticated users to be able to write to. Shout out to Grace Wang for the scoop on this:

RLS Policy

This policy tells Postgres/Supabase to decode the token then compare its' address value with the row's column address. If they're equal, it'll allow the database write. If not, no luck. We do this so only the logged in address can write to rows it "owns."

And that's it! Please let me know if you have questions or comments.

Latest comments (0)