How to authenticateusers with a blockchain wallet and Fauna

How to authenticateusers with a blockchain wallet and Fauna

Implement a one-click, cryptographically secure login flow using MetaMask, with all user data stored in a Fauna database.

·

12 min read

Created by Shadid Haque, Developer Advocate at Fauna

Passwordless authentication using a blockchain wallet strengthens application security by eliminating risky password management practices. It also improves user experience by eliminating the need for passwords. This tutorial teaches you how to implement a one-click, cryptographically secure login flow using MetaMask, with all user data stored in a Fauna database.

Pre-requisites:

  1. Some familiarity with MetaMask wallet
  2. Familiarity with React.js/Next.js

Solution overview

The following is a complete overview of the authentication steps.

  1. The front-end app generates a signature (signed message) using the web3 library and MetaMask wallet.
  2. The signed message is sent to the backend, in this case, a serverless function.
  3. The backend code verifies and retrieves the wallet’s public address.
  4. The backend tries to find a user in Fauna based on the public address. If the user does not exist, it creates a new user.
  5. The backend app then generates an access token from Fauna based on the user’s id.
  6. The backend returns the access token to the front end.
  7. The front end uses the access token to interact with other resources in Fauna.

The following sequence diagram can help you better understand the flow.

Authentication Sequence Diagram

Create a new application

To get started, create a new Next.js application from the example template by running the following command.

npx create-next-app \
  --example https://github.com/fauna-labs/nextjs-metamask-auth \
  --use-npm

Next, make sure you have the MetaMask wallet plugin installed in your browser. The example app gives you a template to start with, so you don’t have to write everything from scratch. Open the pages/index file and notice that in the code, there is a button with the label login with MetaMask. When a user selects this button, the login function runs.

// pages/index.js
...

export default function Home() {

  ...

  const login = async () => {
    const web3 = new Web3(window.ethereum);
    try {
      const accounts = await window.ethereum.send(
        "eth_requestAccounts"
      )
      console.log('accounts', accounts.result[0]);
      const address = accounts.result[0];
      const signed_msg = await Web3Token.sign(msg => web3.eth.personal.sign(msg, address), '1h');
      const response = await fetch('api/user', {
        method: 'POST',
        body: JSON.stringify({
          signed_msg
        }),
      });

      if(response.status !== 200) {
        console.log('API Error/Invalid Fauna Secret')
        return;
      }

      const { token } = await response.json();
      const one_hour = new Date(new Date().getTime() +  3600 * 1000) // sign token for 1 hour
      Cookies.set('fauna-auth', token, { expires: one_hour })
      setLoggedin(true)
    } catch (error) {
      alert('Please Install MetaMask Wallet')
      return;
    }
  }

  return (
    <>
    ...
      <button 
        className="uk-button uk-button-primary uk-button-large" 
        onClick={login}
      >
        Login with MetaMask
      </button> 
    ...
    </>
  )
}

The login function starts by instantiating a new Web3 instance. You can get all the accounts associated with the wallet by calling the following function.

window.ethereum.send("eth_requestAccounts")

You then get the public address from the account and use the public address to generate a signed message using the Web3Token.sign function. Signer messages are unique to each MetaMask account. Using a signer message, you can recover the signing account’s public address. To learn more, visit the web3 documentation .

You send this signer message to your backend with an API call. The backend decodes this message and retrieves the wallet’s public address from it. The backend then searches for a user with that public address in the database and returns the user.

Configuring the backend

In this tutorial, the backend is a serverless function that lives in the pages/api directory of your Next.js application. When you deploy your Next.js application to Vercel , the functions in the directory become a serverless function.

You can also use other alternatives for your backend, such as an AWS >lambda function, a traditional express server, or a Netlify function. The >code should be similar.

Open up the pages/api/user.js file. The handler function runs when you make an API call from your front-end to the API endpoint. Review the code for the handler function.

// pages/api/user.js
... 

export default async function handler(req, res) {
  const {signed_msg} = JSON.parse(req.body);
  const { address, body } = await Web3Token.verify(signed_msg);

  console.log('Public Address Retrieved', address);

  try {
    // Find user 
    ...
  } catch (error) {
    // If user not found in database create a new user
    ...
  }

}

...

In the previous code block, you retrieve the signed message from the request body. You then decode the public address from the signed message. At this point, the database is not configured. However, you can see the retrieved public address in your backend log when you run the application.

Run the application with npm run dev. Navigate to localhost:3000 and choose the Login With MetaMask button.

Login button

Select your account and choose Sign when prompted by your browser.

MetaMask sign menu

Observe the logs in your terminal. Notice the public address is retrieved.

Retrieving the public address in the backend

A wallet’s public address is unique to each user and it is safe to share the wallet's public address with others, making it appropriate to store in the database and use as a unique identifier for a user.

Configure a database in Fauna

Navigate to Fauna in your browser and create a new database. Give your database a name, and choose the /Classic/ Region Group for this demo application.

Creating a database

Choose New Collection to create a new collection called User.

create new collection

Next, create an index to find users by their wallet’s public address. Navigate to Indexes > New Index, select User as your source collection, and name your index user_by_public_address. In the terms field, specify data.public_address. Select the Unique option to ensure each user has their own unique public address and choose Save to create the index.

Creating a new index

Next, generate a new server key in Fauna. Navigate to Security > Keys > New Key. Ensure that you select Server in the Role dropdown. Give your key a name and choose Save. The backend uses this secret key to find users and generate access tokens.

create new key

generated key

Copy the generated key to save as a new environment variable in your Next.js application. Create a file .env.local at the root of your project. The secret from .env.local is used for local development. Add the following code to your .env.local file. Replace fnAxxx.... with your generated secret.

FAUNA_SECRET=fnAxxx....

ℹ️ This file is added to your .gitignore by default. Be careful not to commit this file to your repository or expose the contents of this file!

📘 When you deploy your application to Vercel you add the production environment variables in Vercel. Follow this documentation to add your environment variable in Vercel.

Navigate back to pages/api/user.js file and review the code.

// pages/api/user.js
...
export default async function handler(req, res) {
  ...
  try {
    // Find user 
    const user = await serverClient.query(
      Get(
        Match(Index('user_by_public_address'), address)
      )
    )
    const accessToken = await createAccessToken(user.ref.id, 3600);
    res.status(200).json({ token: accessToken.secret });

  } catch (error) {
    // If user not found in database create a new user
    ...
  }

}

// Generate an access Token For Fauna 
const createAccessToken = (ref, ttl) => {
  return serverClient.query(
    Create(Tokens(), {
      instance: Ref(Collection("User"), ref),
      data: {
        type: "access"
      },
      ttl: TimeAdd(Now(), ttl, "seconds"),
    })
  );
};

First, you try to find a user in the database using the Get and Match functions from the Fauna JavaScript driver to search the user_by_public_address index. If a user is found, you use the user reference and generate a new access token for that user by calling the createAccessToken function.

This access token is then sent back to the frontend. As long as the access token is valid (not expired), the user remains authenticated to the front end. In the frontend, you use this access token to interact with other Fauna resources.

To learn more about how authentication tokens work in Fauna follow this article .

In the catch block of your login function, you check whether the user was not in the database. The following code block demonstrates this.

// pages/api/user.js

export default async function handler(req, res) {
  const {signed_msg} = JSON.parse(req.body);
  const { address, body } = await Web3Token.verify(signed_msg);
  console.log('Public Address Retrieved', address);

  try {
    // Find user 
    ...
  } catch (error) {
    // If user not found in database create a new user
    if(error.name === 'NotFound') {
      const newUser = await registerUser(address)
      const accessToken = await createAccessToken(newUser.ref.id, 3600);
      return res.status(200).json({ token: accessToken.secret });
    }

    // authentication error
    if (error.name === 'Unauthorized') {
      return res.status(401).json({ message: 'Invalid Fauna Secret or Token' });
    }
  }
}

// Registers a new User in FaunaDB
const registerUser = (public_address) => {
  return serverClient.query(
      Create(
        Collection('User'),
        { data: { public_address } },
      )
  );
};

If the user is not in the database, you register a new user. The registerUser function registers a new user based on the wallet’s public address. Once the user is registered, you generate the access token for that user using the user reference and createAccessToken function.

Encapsulating logic with user-defined functions (UDFs) You can also accomplish this logic in a single transaction in Fauna using a user-defined function (UDF). The following code snippet is an example.
clojure Query( Lambda( ["public_address", "ttl"], Let( { user_set: Match(Index("user_by_public_address"), Var("public_address")), user: If( Exists(Var("user_set")), Get(Var("user_set")), Create(Collection("User"), { data: { public_address: Var("public_address") } }) ), token: Create(Tokens(), { instance: Select(["ref"], Var("user")), data: { type: "access" }, ttl: TimeAdd(Now(), Var("ttl"), "seconds") }) }, { user: Var("user"), access_token: Var("token") } ) ) )

Restart the application and run it again with the npm run dev command. Navigate to localhost:3000 in your browser.

Open the Network tab in your browser’s developer tools. Notice the backend now returns a token. You save this token in your local storage or cookies and use the token to interact with Fauna resources directly from your frontend application.

Access token displayed in the network tab

Open the pages/index.js file and review the login function in the following code snippet.

// pages/index.js
...
const login = async () => {
    const web3 = new Web3(window.ethereum);
    try {
      const accounts = await window.ethereum.send(
        "eth_requestAccounts"
      )
      console.log('accounts', accounts.result[0]);
      const address = accounts.result[0];
      const signed_msg = await Web3Token.sign(msg => web3.eth.personal.sign(msg, address), '1h');
      const response = await fetch('api/user', {
        method: 'POST',
        body: JSON.stringify({
          signed_msg
        }),
      });

      if(response.status !== 200) {
        return;
      }

      const { token } = await response.json();
      const one_hour = new Date(new Date().getTime() +  3600 * 1000) // sign token for 1 hour
      Cookies.set('fauna-auth', token, { expires: one_hour })
      setLoggedin(true)
    } catch (error) {
      alert('Please Install MetaMask Wallet')
      return;
    }
}

When you successfully receive a token, you store the token in your browser cookies. You also set the login state to true. A Query Data button and Logout button appear on the screen when the component is in the logged-in state.

Logged in state

When you select the Query Data button, it will query a collection in Fauna using the access token. In the starter code when the Query Data button is selected it tries to query a collection called Movie from your Fauna database. Because this resource doesn’t exist in your database yet, you receive an error.

Query Fauna resources

To fix this error, return to the Fauna dashboard and choose Collections > New Collection. Create a new collection called Movie then add some movies to your collection by running the following command in the Fauna shell.

Map(
  [
    {title: "The Hateful Eight", director: "Quentin Tarantino", release: "December 25, 2015"},
    {title: "Once Upon a Time in Hollywood", director: "Quentin Tarantino", release: "July 26, 2019"},
    {title: "Reservoir Dogs", director: "Quentin Tarantino", release: "Jan 21, 1992"}
  ],
  Lambda(
    "movie",
    Create(Collection("Movie"), { data: Var("movie") })
  )
)

You need to define a new Security Role to allow logged-in users to query this collection using the access key you generate for them. Navigate to Security > Roles > New Role. Create a new role called AuthRole. In the Privilege tab add Movie collection to your role and provide Read and Create permissions.

Adding privileges on the Movie collection to the role.

Navigate to the Membership tab, add the User collection as a member collection, and choose Save to create the role. Now any token created by a member of the User collection has Read and Create permissions on documents in the Movie collection.

membership

Review the queryData function in the following code. Notice that it retrieves the access token from browser cookies and queries the Movie collection using the access token.

// pages/index.js

const queryData = async () => {
    const authToken = Cookies.get('fauna-auth');
    const faunaClient = new faunadb.Client({ secret: authToken });
    const movies = await faunaClient.query(
      Map(
        Paginate(Documents(Collection('Movie'))),
        Lambda(x => Get(x))
      )
    )
    console.log('--->', movies);
}

You get something similar to the following response in your browser console when an authenticated user selects the Query Data button.

querying data in Fauna

The following is the complete final code for the pages/index.js file.

// pages/index.js

import Head from 'next/head'
import { useEffect, useState } from 'react'
import Cookies from 'js-cookie' 
import Web3 from 'web3';
import Web3Token from 'web3-token';
import styles from '../styles/Home.module.css'
import faunadb, { 
  Get,
  Paginate,
  Documents,
  Collection,
  Lambda,
  Map
} from 'faunadb';

export default function Home() {

  const [isLoggedin, setLoggedin] = useState(false);

  useEffect(() => {
    const authToken = Cookies.get('fauna-auth');
    if(authToken) {
      setLoggedin(true)
    }
  }, []);

  const login = async () => {
    const web3 = new Web3(window.ethereum);
    try {
      const accounts = await window.ethereum.send(
        "eth_requestAccounts"
      )
      console.log('accounts', accounts.result[0]);
      const address = accounts.result[0];
      const signed_msg = await Web3Token.sign(msg => web3.eth.personal.sign(msg, address), '1h');
      const response = await fetch('api/user', {
        method: 'POST',
        body: JSON.stringify({
          signed_msg
        }),
      });

      if(response.status !== 200) {
        return;
      }

      const { token } = await response.json();
      const one_hour = new Date(new Date().getTime() +  3600 * 1000) // sign token for 1 hour
      Cookies.set('fauna-auth', token, { expires: one_hour })
      setLoggedin(true)
    } catch (error) {
      alert('Please Install MetaMask Wallet')
      return;
    }
  }

  const logout = () => {
    Cookies.remove('fauna-auth')
    setLoggedin(false)
  }

  const queryData = async () => {
    const authToken = Cookies.get('fauna-auth');
    const faunaClient = new faunadb.Client({ secret: authToken });
    const movies = await faunaClient.query(
      Map(
        Paginate(Documents(Collection('Movie'))),
        Lambda(x => Get(x))
      )
    )
    console.log('--->', movies);
  }

  return (
    <>
    <Head>
      <title>My page title</title>
      <meta name="viewport" content="initial-scale=1.0, width=device-width" />
      <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.9.2/dist/css/uikit.min.css" />
    </Head>
    <div className={styles.container}>
      <main className={styles.main}>
        {isLoggedin ? (
          <>
            <button   
              className="uk-button uk-button-default" 
              style={{ marginBottom: '10px' }}
              onClick={queryData}
            >
              Query Data
            </button>
            <button className="uk-button uk-button-danger" onClick={logout}>Logout</button>
          </>
        ) : 
        <button className="uk-button uk-button-primary uk-button-large" onClick={login}>Login with MetaMask</button> 
        }
      </main>
    </div>
    </>
  )
}

Logging out users

You log out users by removing the access token from the cookie store in your application. The following code snippet is executed when a user selects the Logout button.

// pages/index.js
...
const logout = () => {
    Cookies.remove('fauna-auth')
    setLoggedin(false)
}

Conclusion

You can now build an application with Fauna that authenticates users securely with MetaMask, creates access tokens, and provides limited access to your application’s backend resources.

Where to go from here

If you are interested in more Fauna authentication-related content, follow the Authentication tag in the Fauna blog. If you are new to Fauna and would like to learn how Fauna leverages attribute-based access control (ABAC) to provide granular access to users, follow this tutorial. If you have any feedback, please let us know in the Fauna Forums .