Supabase & Metamask Signed Authentication (Web3)
Tags: web3, tutorial, blockchain, javascript
Published: 2023-01-26 01:38:09

Supabase is a great tool. But currently lacks the ability to natively use a [Web3 Provider to authenticate](https://github.com/supabase/gotrue/pull/282). 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](https://supabase.com/) account
   1. `JWT` Key and `Service` Key
2. Supabase `public.users` table
3. A Client able to interact with Wallet Provider 
   1. [ethers.js](https://github.com/ethers-io/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](https://docs.ethers.org/v5/) 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:


![`public.users` table with mock data](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qd9i31w6xvi2ytxmqe8n.png)



#### 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 })
```
#### 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()](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3k9mvhdn9owjh674poxk.png)

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 
    }
  )

```

#### 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

``` 

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)

```

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](https://medium.com/@gracew/using-supabase-rls-with-a-custom-auth-provider-b31564172d5d) for the scoop on this:

![RLS Policy](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/myt2ump0i8x6shq2v665.png)


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.