DEV Community

Cover image for Build a Web3 authentication flow with React, Ether.js, and Ceramic
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Build a Web3 authentication flow with React, Ether.js, and Ceramic

Written by David Ekanem✏️

Introduction

A key aspect of Web3 is that blockchain wallets and public-key encryption can be used for identifying accounts. In this article, we’ll explore how to create a one-click, cryptographically-secure login flow using a blockchain wallet, using the Ether.js library to interact with the blockchain, through the following steps:

What are cryptographically-secure blockchains used for?

As stated above, cryptographically-secure blockchains prove the ownership of an account by signing a piece of data using a private key as a result of their very nature. These cryptographic signatures can also be used to write transactions to a blockchain.

We’ll proceed to build a message-signing-based authentication mechanism with a user’s public address as their identifier.

What are Web3 wallets used for?

Web3 wallets are used for authentication in Web3 environments. There are three leading Web3 wallet options at the time of writing this article:

  1. MetaMask
  2. WalletConnect
  3. Web3Auth

We’ll be using MetaMask for this tutorial. Now that we’ve laid out the project, let’s get started.

Setting up our React app with Ceramic

To use Ceramic, we’ll need Node.js ≥ v14 and npm ≥ v6, and we’ll create a new React application with Next.js.

yarn create next-app --typescript web3-auth
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

yarn add web3modal ethers @ceramicnetwork/http-client key-did-resolver @ceramicnetwork/3id-did-resolver key-did-provider-ed25519 @stablelib/random
@ceramicnetwork/stream-tile dids
Enter fullscreen mode Exit fullscreen mode

Ceramic relies on the decentralized identifier (DID) standard for user accounts, which are compatible with all blockchain wallets. DIDs are useful as they serve to unbundle Ceramic accounts from any Web3 wallet address or public key as a way of confirming the true owner of an account.

Hosting data with Ceramic

Our application will rely on the Ceramic data network to store, modify, and retrieve data. Relying on Ceramic’s data models, we’ll create a basic user registry, and user accounts in the registry will conform to the standard DID specification. The Ceramic client enables authenticated accounts to perform transactions on the network.

Creating a Ceramic user registry

Below is the necessary code for creating a Ceramic user registry. The http-client allows our application to connect to a remote Ceramic node over HTTP to read and write streams. Throughout this project, TypeScript will be used.

mkdir utils
touch client.tsx

import { CeramicClient } from "@ceramicnetwork/http-client";
import KeyDidResolver from "key-did-resolver";
import ThreeIdResolver from "@ceramicnetwork/3id-did-resolver";
import { Ed25519Provider } from "key-did-provider-ed25519";
import { randomBytes } from "@stablelib/random";
import { TileDocument } from "@ceramicnetwork/stream-tile";
import { DID } from "dids";

// set ceramic node URL
const API_URL = "https://ceramic-clay.3boxlabs.com";

// generate seed
const seed = randomBytes(32);

// create provider
const provider = new Ed25519Provider(seed);

// create ceramic instance
const ceramic = new CeramicClient(API_URL);

// set provider to ceramic
ceramic.did?.setProvider(provider);

await ceramic.did?.authenticate();

// DID methods to authenticate writes
const resolver = {
  ...KeyDidResolver.getResolver(),
  ...ThreeIdResolver.getResolver(ceramic),
};

// create a DID instance
const did = new DID({ resolver });

// set DID instance
ceramic.did = did;

const doc = await TileDocument.create(ceramic, { broadcast: "Hello" });

export const users: any = { doc };
Enter fullscreen mode Exit fullscreen mode

Implementing a Web3 authentication flow in React

The auth API checks if the user’s account exists in the user registry. If no such user has been created, a new user is created and a cryptographic nonce is assigned to them.

touch src/pages/api/auth.tsx
code src/pages/api/auth.tsx
import type { NextApiRequest, NextApiResponse } from "next";

import { users } from "../../utils/client";
export default async function auth(req: NextApiRequest, res: NextApiResponse) {
  const { address } = req.query;
  let user = users[address as string];

  // check if user exists in register
  if (!user) {
    user = {
      address,
      // update user nonce
      nonce: Math.floor(Math.random() * 10000000),
    };
    users[address as string] = user;
  } else {
    // create nonce for new user
    const nonce = Math.floor(Math.random() * 10000000);
    // assign nonce to new user
    user.nonce = nonce;
    users[address as string] = user;
  }
  res.status(200).json(user);
}
Enter fullscreen mode Exit fullscreen mode

The nonce that was generated in our auth file is used as a unique string to sign the request from the client. This enables the authentication of transactions on the server. Once the signature is created, it is sent along with the request.

touch pages/api/confirm.tsx
code pages/api/confirm.tsx
Enter fullscreen mode Exit fullscreen mode

The nonce is extremely important because it’s used to decode the transaction on the server. Once it confirms that the decoded string matches the address of the caller, the system can confirm that the transaction was sent by the same user.

In the confirm.tsx file, the decoded string is checked, to make sure it matches the address of the caller.

import type { NextApiRequest, NextApiResponse } from "next";
import { ethers } from "ethers";
import { users } from "../../utils/client";

export default function transactionCheck(
  req: NextApiRequest,
  res: NextApiResponse
) {
  let authenticated = false;

  const { address1, signature } = req.query;
  const user = users[address1 as string];
  const address = address1 as string;
  const decodedAddress = ethers.utils.verifyMessage(
    user.nonce.toString(),
    signature as string
  );
  if (address.toLowerCase() === decodedAddress.toLowerCase())
    authenticated = true;
  res.status(200).json({ authenticated });
}
Enter fullscreen mode Exit fullscreen mode

Adding multiple providers with Web3Modal

The Web3Modal library is a simple Web3/Ethereum provider solution, which enables the addition of support for multiple providers in the application. The Web3Modal library supports the injected providers that we’ll rely on in this project, MetaMask and Tor.us.

In the Home.module.css file, create the styling for the application container and login button.

touch styles/Home.module.css
.container {
  width: 30rem;
  margin: 100px auto;
}

.button {
  width: 100%;
  margin: 0.2rem;
  padding: 0.8rem;
  border: none;
  background-color: purple;
  color: white;
  font-size: 16;
  cursor: pointer;
}
Enter fullscreen mode Exit fullscreen mode
touch pages/index.tsx
import type { NextPage } from "next";
import React, { useState } from "react";
import { ethers } from "ethers";
import Web3Modal from "web3modal";
import styles from "../styles/Home.module.css";

const Home: NextPage = () => {
  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,
        },
      },
    });
    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 Login() {
    const authData = await fetch(`/api/authenticate?address=${account}`);
    const user = await authData.json();
    const provider = new ethers.providers.Web3Provider(connection as any);
    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 className={styles.container}>
      {!connection && (
        <button className={styles.button} onClick={connect}>
          Connect Wallet
        </button>
      )}
      {connection && !loggedIn && (
        <>
          <button className={styles.button} onClick={Login}>
            Login
          </button>
        </>
      )}
      {loggedIn && <h2>Let's get started, {account}</h2>}
    </div>
  );
};

export default Home;
Enter fullscreen mode Exit fullscreen mode

The connect function prompts the user with the Web3Modal to login with the available injected Tor.us Web3 wallet that has been specified in the application.

The login function connects the user registry and updates the user’s nonce. Once the nonce is verified on the server from the signature, the UI updates.

Once the nonce is verified, it updates the UI

Conclusion

In this article, we built a cryptographically-secure login flow and explained how a digital signature can be confirmed with the user’s nonce. We also demonstrated how the nonce can be used to prove ownership of an account, and therefore provide authentication.


Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket signup

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — start monitoring for free.

Top comments (0)