How to build a (possibly) secure Solana login flow for Supabase

The back end for this site is Supabase, my current weapon of choice for simple projects.

So let's build a way to "log in" to Supabase with a Solana wallet.

I've YOLO'd this into production on this site, because at this stage, the only threat is that someone sets an NFT avatar for a public key which they don't have the private key for, on a website that no one reads. You may not want to do this.

Note: you could easily adapt this to work with Hasura or any other service which supports JWT authentication.

Inspiration

This is heavily based on the hard work of other people:

Ethereum Improvement Proposal 4361

GoTrue PR #282

Using Next.js and Auth0 with Supabase

Thanks!

The flow

1. The client requests a nonce

2. The API responds with a recent Solana block number

3. The client signs the message

4. The API verifies the message, and checks that the Solana block was created in the last 60 seconds

5. The API signs a JWT containing the public key of the user, using the same JWT secret as our Supabase instance

6. The client uses that JWT to authenticate with our Supabase instance

We're using Nuxt API routes to handle the nonce, verification and JWT signing, though you could do this in any number of ways – a Next.js API routes, a Cloudflare Worker, or any a monolithic back end.

Creating a nonce

Easy peasy. Here's server/api/nonce.js:

import { Connection, clusterApiUrl } from '@solana/web3.js'

export default async (req, res) => {
  let connection = new Connection(clusterApiUrl('mainnet-beta'), 'confirmed')
  let recentBlockhash = await connection.getRecentBlockhashAndContext()

  return {
    nonce: recentBlockhash.context.slot
  }
}

Signing the message

async signMessage() {
  const { nonce } = await $fetch('/api/nonce', {
    method: 'POST'
  })

  const message = `Sign this message to log in to stillearly.io // ${nonce}`
  const data = new TextEncoder().encode(message)
  const signature = await this.adapter.signMessage(data, 'hex')
  const verified = await $fetch('/api/verify', {
    method: 'POST',
    body: {
      publicKey: this.adapter.publicKey.toBase58(),
      nonce: nonce,
      signature: base58.encode(signature)
    }
  })
  if (verified.accessToken) {
    this.accessToken = verified.accessToken
  }
}

Ideally, we'd give the user some feedback if something goes wrong here.

Verifying the message and block number

Here's server/api/verify.js:

import config from '#config'

import base58 from 'bs58'
import nacl from 'tweetnacl'

import jwt from 'jsonwebtoken'

import { useBody } from 'h3'
import { $fetch } from 'ohmyfetch'

import { Connection, clusterApiUrl } from '@solana/web3.js'

const BLOCK_VALID_FOR_N_SECONDS = 60

export default async (req, res) => {
  const body = await useBody(req)
  const signatureUint8 = base58.decode(body.signature)
  const pubKeyUint8 = base58.decode(body.publicKey)
  
  // is the nonce a legit recent block?
  const connection = new Connection(clusterApiUrl('mainnet-beta'), 'confirmed')
  const blockTime = await connection.getBlockTime(body.nonce)
  const now = Date.now() / 1000
  const isRecent = (blockTime > (now - BLOCK_VALID_FOR_N_SECONDS)) ? true : false

  const nonceUint8 = new TextEncoder().encode(`Sign this message to log in to stillearly.io // ${body.nonce}`)
  const verified = nacl.sign.detached.verify(nonceUint8, signatureUint8, pubKeyUint8) && isRecent

  let accessToken = null

  if (verified) {
    // create a row in our user table (if it's not there already)
    const url = `${config.SUPABASE_URL}/rest/v1/user`
    const res = await $fetch(url, {
      method: 'POST',
      headers: {
        apikey: config.SUPABASE_API_KEY,
        authorization: `Bearer ${config.SUPABASE_SERVICE_ROLE}`,
        'content-type': 'application/json; charset=utf-8',
        prefer: 'resolution=merge-duplicates'
      },
      body: JSON.stringify({
        public_key: body.publicKey
      })
    })

    // sign jwt
    const payload = {
      publicKey: body.publicKey,
      exp: Math.floor(Date.now() / 1000) + 60 * 60,
    }

    const accessToken = jwt.sign(JSON.stringify(payload), config.SUPABASE_JWT_SECRET)
  
    return {
      accessToken: accessToken
    }
  }

  return {
    verified: verified,
  }
}

Note: if you're using Nuxt like me, ensure that SUPABASE_SERVICE_ROLE and any other secrets are in your privateRuntimeConfig in nuxt.config.js.

privateRuntimeConfig: {
  SUPABASE_JWT_SECRET: process.env.SUPABASE_JWT_SECRET,
  SUPABASE_SERVICE_ROLE: process.env.SUPABASE_SERVICE_ROLE
},

Setting up Supabase

I already had a public.user table with a public_key column set as the primary key.

The next thing to do was create an auth function we can use to get the public key from the JWT.

create or replace function auth.public_key() returns text as $$
  select nullif(current_setting('request.jwt.claim.publicKey', true), '')::text;
$$ language sql stable;

Now to add a policy to the public.user table. This assumes you already have row-level security enabled. You can do all of this via the Supabase UI if you want.

create policy "enable update for users based on public key" on public.user for update using (auth.public_key() = public_key) with check (auth.public_key() = public_key);

using (auth.public_key() = public_key) ensures that the user can't update any rows in the table which don't have their public key in the public_key column. with check ensures that the user can't change a row which does have their public key into one that doesn't.

For more details you can check out the create policy documentation.

Passing the JWT into Supabase

Assuming you're using Supabase like this:

const supabase = createClient(FOO, BAR)

Just add this:

supabase.auth.setAuth(THE_JWT)

All done!

In my case, now you can choose an avatar from NFTs which you own.

Now to actually do something with them. Until next time!

A WIP project by
@timhnln