David Ekanem In love with technology and discussions around technology.

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

4 min read 1250

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


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

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

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

await ceramic.did?.authenticate();

// DID methods to authenticate writes
const resolver = {

// 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 };

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 = {
      // 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;

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

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(
    signature as string
  if (address.toLowerCase() === decodedAddress.toLowerCase())
    authenticated = true;
  res.status(200).json({ authenticated });

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;
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();

  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(
    const data = await response.json();

  return (
    <div className={styles.container}>
      {!connection && (
        <button className={styles.button} onClick={connect}>
          Connect Wallet
      {connection && !loggedIn && (
          <button className={styles.button} onClick={Login}>
      {loggedIn && <h2>Let's get started, {account}</h2>}

export default Home;

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


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.

Cut through the noise of traditional React error reporting with LogRocket

LogRocket is a React analytics solution that shields you from the hundreds of false-positive errors alerts to just a few truly important items. LogRocket tells you the most impactful bugs and UX issues actually impacting users in your React applications. LogRocket automatically aggregates client side errors, React error boundaries, Redux state, slow component load times, JS exceptions, frontend performance metrics, and user interactions. Then LogRocket uses machine learning to notify you of the most impactful problems affecting the most users and provides the context you need to fix it.

Focus on the React bugs that matter — .

David Ekanem In love with technology and discussions around technology.

Leave a Reply