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:
-
Supabase account
-
JWT
Key andService
Key
-
- Supabase
public.users
table - A Client able to interact with Wallet Provider
- ethers.js and MetaMask
-
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:
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:
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 for the scoop on this:
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.
Top comments (0)