Building a Web3 Authentication Flow

My name is Nader Dabit. I work as a Developer Relations engineer with Edge & Node and The Graph.

The full code for this project is located here.

Web3 authentication is one of the topics that resonates the most with developers I know when talking about web3.

Unlike centralized services that collect and track your personal information and user profiles, in web3 we can use blockchain wallets and public key encryption to identify ourselves. We can then choose whether or not to tie our own identity to this address using protocols like IDX or ENS, or to have multiple addresses for various types of applications and use cases.

Cryptographic signatures are used to prove ownership of an address. We can use these signatures to write transactions to a blockchain, but also to sign messages of any type and decode them on a server using web3 libraries like web3.js and ethers.js.

Using this technique, we can verify whether the sender of a message is indeed authentic, enabling us to perform authentication and authorization in just a few lines of code.

In this post, I want to walk through how to implement this in a Next.js application, building out a basic example of how a real-world authentication flow might look. I also want to show how to set up a cross-platform UI that will allow users to choose from various wallets whether they are accessing your app from a web or mobile browser.

The way we’re going to accomplish this is by using the ethers.js library, and the signMessage and verifyMessage utilities of the library.

For instance, we can sign a message from a client side application using the user’s blockchain wallet like this:

const signature = await signer.signMessage(some_string)

Then, verify it on a server using a combination of the signature, address, and string:

const decodedAddress = ethers.utils.verifyMessage(some_string, signature)

if (decodedAddress === sender_address) {
  // signature is verified
}

Let’s look at how we can implement this technique in a full stack application, while also including other important pieces of the authentication flow like various wallet options, and making it work cross-platform.

To use the app we are about to build, you will need to have a web3 wallet like Metamask or Rainbow installed on your device, or as an extension in your web browser.

Shout out to Rahat Chowdhury, who’s ss-workshop was expanded upon for this blog post and example project.

Getting started

To get started, let’s first create a new Next.js application:

npx create-next-app sign-in-with-ethereum

Next, change into the new directory and install the following dependencies using either npm or yarn:

npm install ethers web3modal @walletconnect/web3-provider @toruslabs/torus-embed

Next, in the pages/api folder create a new file named auth.js and add the following code:

import { users } from '../../utils/users'

export default function auth(req, res) {
  const {address} = req.query
  let user = users[address]
  if (!user) {
    user = {
      address,
      nonce: Math.floor(Math.random() * 10000000)
    }
    users[address] = user
  } else {
    const nonce = Math.floor(Math.random() * 10000000)
    user.nonce = nonce
    users[address] = user
  }
  res.status(200).json(user)  
}

This is a basic user API that will check to see if a user exists in the registry. If they do, we update the user’s nonce (which will be used to verify the user in the next step) and return the user info back to the client. If the user has not yet been created, we create a new user and assign them a nonce.

Next, create a file in the api directory named verify.js with the following code:

/* pages/api/verify.js */
import { ethers } from 'ethers'
import { users } from '../../utils/users'

export default function verify(req, res) {
  let authenticated = false
  const {address, signature} = req.query
  const user = users[address]
  const decodedAddress = ethers.utils.verifyMessage(user.nonce.toString(), signature)
  if(address.toLowerCase() === decodedAddress.toLowerCase()) authenticated = true
  res.status(200).json({authenticated})
}

This code is all that we need to authenticate a transaction on the server. The signature is created on the client and sent along with the request, signed with some unique string, in our case a nonce that was generated on the server in the previous step. We use that nonce to decode the transaction on the server, and if the address of the decoded string matches the address of the caller, we can assume the transaction was sent by the same user.

Next, create a folder in the root directory called utils and create a new file there named users.js with the following code:

/* utils/users.js */
export const users = {}

This is a basic user registry that is probably not something you would use in production, but allows us to simulate how something like this might be done in a real-world application. The back end for this could be anything from a decentralized protocol like Ceramic, ThreadDB, or GunDB, to a centralized service or DB.

Next, let’s update pages/index.js with the client-side code. First, remove all of the existing code, and add the following imports and variable declaration:

/* pages/index.js */
import React, { useState } from 'react'
import { ethers } from 'ethers'
import Web3Modal from 'web3modal'
import WalletConnectProvider from '@walletconnect/web3-provider'

Here we’ve imported both Web3Modal and WalletConnect, which will allow users to have a nice variety of options in connecting their web3 wallet.

Next, let’s create the main UI. (the code for this file is also located here)

You can get an an Infura project ID for free here. If you do not want to sign up for Infura, you can leave out the walletconnect object and you will still have Torus and Metamask options.

/* pages/index.js */
const ConnectWallet = () => {
    const [account, setAccount] = useState('')
    const [connection, setConnection] = useState(false)
    const [loggedIn, setLoggedIn] = useState(false)

    async function getWeb3Modal() {
      let Torus = (await import('@toruslabs/torus-embed')).default
      const web3Modal = new Web3Modal({
        network: 'mainnet',
        cacheProvider: false,
        providerOptions: {
          torus: {
            package: Torus
          },
          walletconnect: {
            package: WalletConnectProvider,
            options: {
              infuraId: 'your-infura-id'
            },
          },
        },
      })
      return web3Modal
    }

    async function connect() {
      const web3Modal = await getWeb3Modal()
      const connection = await web3Modal.connect()
      const provider = new ethers.providers.Web3Provider(connection)
      const accounts = await provider.listAccounts()
      setConnection(connection)
      setAccount(accounts[0])
    }

    async function signIn() {
      const authData = await fetch(`/api/auth?address=${account}`)
      const user = await authData.json()
      const provider = new ethers.providers.Web3Provider(connection)
      const signer = provider.getSigner()
      const signature = await signer.signMessage(user.nonce.toString())
      const response = await fetch(`/api/verify?address=${account}&signature=${signature}`)
      const data = await response.json()
      setLoggedIn(data.authenticated)
    }

    return(
        <div style={container}>
          {
            !connection && <button style={button} onClick={connect}> Connect Wallet</button>
          }
          { connection && !loggedIn && (
            <div>
              <button style={button} onClick={signIn}>Sign In</button>
            </div>
          )}
          {
            loggedIn && <h1>Welcome, {account}</h1>
           }
        </div>
    )
}

const container = {
  width: '900px',
  margin: '50px auto'
}

const button = {
  width: '100%',
  margin: '5px',
  padding: '20px',
  border: 'none',
  backgroundColor: 'black',
  color: 'white',
  fontSize: 16,
  cursor: 'pointer'
}

export default ConnectWallet

The connect function will prompt the user to sign in with the wallet of their choice. Once they connect, their wallet address will be stored in the local state. If they are on a mobile device, WalletConnect allows them to authenticate with their mobile wallet and will then redirect them back to the app to continue

The signIn function will connect with the user registry and generate the nonce. It will then return the nonce to the client allowing the user to sign the transaction with the nonce. On the server, we’ll verify that the address retrieved from the signature signed with the nonce retrieved from the server matches the user’s address. If this does match, the user is then authenticated and we’ll update the UI accordingly.

Testing it out

To build and run the app, run the following command:

npm run build && npm start

When the app loads, you should be able to sign in with your ethereum wallet.

The full code for this project is located here.

This royalties for this post will be split with Rahat Chowdhury, who’s eth-sso-workshop I expanded on for this tutorial.

Next steps

Here are a few other projects to look at if you’re interested in web3 identity.

  1. Ceramic & IDX. Also, here’s a video and a repo showing how to use it.
  2. Spruce sign in with Ethereum SSR example.
  3. ENS - get user profiles from ENS domain
Subscribe to Nader Dabit
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.