Creating a Web3 Login with Ethereum/MetaMask Integrated with Firebase Auth

MetaMask is a browser extension and app that describes itself as a crypto wallet and a gateway to blockchain apps. MetaMask helps you manage your private key which controls your Ethereum address, and facilitates crypto transactions and interacting with blockchain apps.

The important thing to know for developers is that if a user has the MetaMask extension installed, we will be able to access the window.ethereum object in our application to interact with MetaMask and the private key management tools it provides for the users public Ethereum address.

In this tutorial, we are going to build an authentication solution that allows a user to login with MetaMask. Their public Ethereum address will be used as a unique identifier, and we will use the private key management tools that MetaMask exposes to provide a mechanism for a user to prove they own that particular address and thus have permission to login as that user.

The principles for this authentication solution could be used with any framework and most backends, but we are specifically going to be using:

  • Ionic/Angular for the front end
  • Firestore to hold the data that we want users to be authenticated for
  • Firebase Auth to manage the users session

Here's what it will look like:

If you want to see a video overview of this entire tutorial, you can check it out here.

Outline

Source code

The concept

The process we are implementing doesn't actually require interacting with the blockchain, it just utilises the private key management features that MetaMask provides in order to create a signature that proves you own that particular Ethereum address.

If you can prove you own that Ethereum address, then you can log into an app with that address as the unique identifier for your user account. The technology powering this is asymmetric/public key encryption which has been used for decades - perhaps most notably it is what enables SSL which allows us to use HTTPS. Public keys (which anyone is allowed to see) and private keys (which must be kept secret) can be used to encrypt data.

I don't want to make this an overview of how public key encryption works, but these are the main concepts:

  • With asymmetric encryption only one party knows the private key, with symmetric encryption both parties know the private key
  • For this reason, in a general sense, asymmetric encryption is more secure (although symmetric encryption is faster and has its own use cases)

Let's take a closer look at asymmetric encryption:

  • If a public key is used to encrypt data, then the private key can be used to decrypt the data
  • If a private key is used to encrypt data, then the public key can be used to decrypt the data

The interesting thing about this is that if you have a private key, and you let everyone know what you public key is, anybody can encrypt some data using your public key and only you will be able to decrypt it with your private key.

However, we aren't actually interested in encrypting data in this case. We aren't trying to send a secret message from our user to the backend. We are trying to get the user to prove that they own the secret private key used to generate the Ethereum address (which is derived from the public key related to that private key). We could do that easily if the user just gave us their private key, but the user absolutely should not do that - the private key is supposed to remain secret and known only to them.

This is where the concept of a digital signature comes into play. A digital signature provided by the user, using their private key, will allow us to verify that they control the private key used to create the Ethereum address without them needing to actually reveal their private key.

The overall concept will work something like this:

  1. User: Hi, I want to sign into the account associated with this Ethereum address: 0x56e6A776e65827Ac23A67637C7FCd475991AdDA1
  2. Server: Great! Here's a random message, I'm going to need you to sign it with the private key for that address to prove that you own the address
  3. User: [sends back message signed using their private key]
  4. Server: [verifies the signature was created with the address the user is attempting to log in for]

Can I use this today?

Yes, you can. Just keep the following in mind.

I don't think MetaMask or logging in with a wallet in general is widely adopted enough yet to use this as your sole authentication method unless you are specifically targeting users who are comfortable with using something like MetaMask.

You could certainly include it as an additional login option to your existing email/social methods right now.

This is an emerging technology, and this series of articles is for people who are interested in exploring the cutting edge. If I had to guess I would think that crypto wallets or generic public/private key tools for facilitating this kind of authentication method will become more standard in browsers in the future. The benefits, in my opinion, are quite strong:

  1. Users can authenticate with websites without having to reveal any information about themselves
  2. You don't have to worry about a website being hacked and revealing your password
  3. Payments can be facilitated directly
  4. If cryptocurrency payments become standard, we can use the same private key to facilitate transactions or send money instantly to anyone without 3rd parties needing to be involved

...but then again maybe this idea of users having complete cryptographic control over their own identity and assets rather than 3rd parties will never be fully realised!

DISCLAIMER: I have taken as much care as I can when creating this article, but I can not provide a guarantee that the process I have outlined is acceptably secure for your purposes. As far as I am aware this process is reasonably solid, but it is also something I've developed over the course of about a day for a tutorial on the Internet, and I'm not some leet hacker or a cryptography expert.

There are also some edge cases that aren't handled by the code below (like multiple accounts, a user changing accounts after connecting their wallet, etc), so keep that in mind.

How does Firebase fit into this picture?

Ok, so this concept might be cool and everything, but how do we actually integrate this into the backend? What if I want to authenticate my users in Firebase this way?

If we were doing this on a server/backend we completely control then implementing this process would be relatively straight-forward. This is the general idea:

  1. Store a public key or address for each user
  2. Generate and store a random nonce for each of these users
  3. When a user wants to authenticate, send them the nonce
  4. Have the user sign the nonce using their private key and send it back
  5. On the backend, verify that the signature is valid
  6. Complete the authentication in whatever way you like (e.g. send them a JWT/Set a Session ID in a cookie)

NOTE: Not sure what a nonce is? Although it is not an exact definition you can think of it as a "number used only once". This is just a random number that we generate, and we will update it with a new random nonce each time the user signs in. The idea is that we want the user to have to provide a new digital signature each time they sign in (so we want them to sign a different bit of data each time). This helps protect from replay attacks where an attacker could try to capture and resend the same signature to the server again to authenticate as that user. If a new signature is required each time then this won't work for the attacker.

We can't just do whatever we want with Firebase, we don't have complete control over the backend in this case - we need to play by Firebases's rules, and Firebase does not have an authWithMetaMask option like they do for Twitter, GitHub, Facebook and so on.

However, Firebase does provide the ability to create whatever kind of authentication process you want by using a Custom Token. The idea is that we can perform whatever kind of authentication we want on our own backend, and once we are satisfied that the user has been authenticated we can generate a Custom Token with Firebase that the user will be able to use to sign in with Firebase.

Setting up Firebase/Firestore/Authentication

This tutorial is already going to be quite long, so I'm not going to be covering any specifics related to how Firebase Authentication, Firestore, or Cloud Functions work. I am just going to provide an outline of what you will need to do to set up your project appropriately. If you do not already know how Firebase works then there is no specific need to use Firebase for this process, you could implement it in some other way. If you do still want to use Firebase, I would recommend taking a look at their documentation or seeking out additional tutorials.

You will need to set up a Firebase project in your Angular application that uses:

  • Authentication
  • Firestore
  • Cloud Functions

There are a couple of additional things to keep in mind that are specific to what we are doing here that need to be dealt with:

  1. Although we are just going to be using a custom token to authenticate a user, you will need to enable at least one method of the sign in methods through the Firebase console (e.g. Email/Password). Otherwise we will get errors complaining that Authentication is not configured.

  2. Using Cloud Functions requires the Blaze plan. You will still be able to use a certain amount of resources for free, but you will need a payment method associated with your pay-as-you-go plan. Although there is quite a high limit for free usage, I highly recommend setting budget alerts whenever you actually have a payment method associated with your account to help catch unwanted spending (i.e. if your cloud functions are costing you more than you expect, or if someone is maliciously trying to rack up charges for your account).

  3. In order to create Custom Tokens you need to enable the IAM Service Account Credentials API for your project. This can take a few minutes to propagate after enabling it.

You may also need to go to the IAM admin page and perform the following steps:

  1. Select your project
  2. Click the edit button on the service account you are using
  3. Click Add Another Role
  4. Type and select Service Account Token Creator
  5. Click Save

You will likely also want to set your own security rules, but for reference, these are the security rules I am using for Firestore for this example:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    match /messages/{message} {
      allow read: if request.auth != null;      
    }

    match /{document=**} {
      allow read, write: if false;
    }
  }
}

Building the Frontend

I am using Ionic/Angular for this example, but again, I'm not going to be explaining what is going on with the Angular side of things. I will just be generically explaining the general process. If you are using Angular and you are not overly familiar with RxJS operators like switchMap you might be interested in a video I have that explains how to think about and discover what the various operators do: 3 Levels of Learning RxJS Operators.

We are just going to be focusing on the bits relevant to the authentication process itself, for the full example make sure to check out the source code for this article.

Everything in the front end centers around the use of an AuthService that provides a signInWithMetaMask() method:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Auth, signOut, signInWithCustomToken } from '@angular/fire/auth';
import { from } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import detectEthereumProvider from '@metamask/detect-provider';

interface NonceResponse {
  nonce: string;
}

interface VerifyResponse {
  token: string;
}

@Injectable({
  providedIn: 'root',
})
export class AuthService {
  constructor(private http: HttpClient, private auth: Auth) {}

  public signOut() {
    return signOut(this.auth);
  }

  public signInWithMetaMask() {
    let ethereum: any;

    return from(detectEthereumProvider()).pipe(
      // Step 1: Request (limited) access to users ethereum account
      switchMap(async (provider) => {

      }),
      // Step 2: Retrieve the current nonce for the requested address
      switchMap(() =>

      ),
      // Step 3: Get the user to sign the nonce with their private key
      switchMap(
        async (response) =>
          //
      ),
      // Step 4: If the signature is valid, retrieve a custom auth token for Firebase
      switchMap((sig) => 
          //
      ),
      // Step 5: Use the auth token to auth with Firebase
      switchMap(
        async (response) =>
          //
      )
    );
  }

  private toHex(stringToConvert: string) {
    return stringToConvert
      .split('')
      .map((c) => c.charCodeAt(0).toString(16).padStart(2, '0'))
      .join('');
  }
}

This is the basic structure of the process with the actual implementation of the authentication removed. The idea here is that the signInWithMetaMask method will return an observable (again, it doesn't need to be an observable) that we can subscribe to in order to trigger the authentication process and notify us when it has completed.

We can then use it elsewhere in the application like this:

    this.authService.signInWithMetaMask().subscribe(
      () => {
        this.navCtrl.navigateForward('/dashboard');
      },
      (err) => {
        console.log(err);
      }
    );

The observable pipes on multiple switchMap operators to take the result of the previous operation and pass it on to the next step. The basic idea with switchMap is that it will take the result from one observable stream, and then return a new observable stream in its place (and we will be able to utilise the value from the previous stream). This is basically just a neat way to prevent us from needing to have nested subscribe calls, as each step needs to wait for the result from the previous step.

I've added comments for each of these steps, and you can see that it lines up with the explanation of the general concepts that we already covered. An important thing to note here is that we are using:

detectEthereumProvider()

When MetaMask is installed in a users browser, it will add a window.ethereum object to the document that our application will be able to interact with to do things like request access to the users Ethereum account, get their current address, trigger transactions, and so on. In our case, we will want to use this ethereum object to:

  1. Request access to the users Ethereum account
  2. Request that the user sign some data with that account

The detectEthereumProvider method from the @metamask/detect-provider package will handle detecting if this is available for us and handles adding typing information to the window object.

The final thing to note here is that we also have a toHex helper method. If we want our nonce to be displayed to the user in their MetaMask account (when we ask them to sign it) in a human readable format then we will need to convert it to a hex format. This isn't strictly required, but it does make for a nicer experience (otherwise the user might end up seeing a blank message, or weird characters/glyphs, which just makes the whole operation seem a bit shadier).

Now let's look at the complete implementation:

  public signInWithMetaMask() {
    let ethereum: any;

    return from(detectEthereumProvider()).pipe(
      // Step 1: Request (limited) access to users ethereum account
      switchMap(async (provider) => {
        if (!provider) {
          throw new Error('Please install MetaMask');
        }

        ethereum = provider;

        return await ethereum.request({ method: 'eth_requestAccounts' });
      }),
      // Step 2: Retrieve the current nonce for the requested address
      switchMap(() =>
        this.http.post<NonceResponse>(
          'https://us-central1-ionic-angular-web3.cloudfunctions.net/getNonceToSign',
          {
            address: ethereum.selectedAddress,
          }
        )
      ),
      // Step 3: Get the user to sign the nonce with their private key
      switchMap(
        async (response) =>
          await ethereum.request({
            method: 'personal_sign',
            params: [
              `0x${this.toHex(response.nonce)}`,
              ethereum.selectedAddress,
            ],
          })
      ),
      // Step 4: If the signature is valid, retrieve a custom auth token for Firebase
      switchMap((sig) =>
        this.http.post<VerifyResponse>(
          'https://us-central1-ionic-angular-web3.cloudfunctions.net/verifySignedMessage',
          { address: ethereum.selectedAddress, signature: sig }
        )
      ),
      // Step 5: Use the auth token to auth with Firebase
      switchMap(
        async (response) =>
          await signInWithCustomToken(this.auth, response.token)
      )
    );
  }

Let's expand on each step with reference to the code:

  • Step 1: Check that MetaMask is installed, and if it is request access to the users Ethereum account
  • Step 2: Make a request to one of our cloud functions to retrieve a nonce for that specific address
  • Step 3: Trigger the personal_sign method with MetaMask and request that the user signs the nonce with their address
  • Step 4: Send the signed nonce to our other cloud function and check that it is valid, if it is, send back a custom token for Firebase
  • Step 5: Use the returned token to sign in with Firebase

If any of the steps throughout this process fail it will cause the observable to error out, which we can handle when we call the method:

    this.authService.signInWithMetaMask().subscribe(
      () => {
        // Handle success
        this.navCtrl.navigateForward('/dashboard');
      },
      (err) => {
        // Handle error
        this.isAuthenticating = false;
      }
    );

Building the Backend

With the front end in place, let's implement those cloud functions for our backend! In order to make verifying the signed nonce easier, we will be using a package called @metamask/eth-sig-util. Make sure to install this inside of your cloud functions folder:

npm install @metamask/eth-sig-util

Again, let's create a bit of a skeleton for our cloud functions first and then we will focus on the implementation details:

import * as functions from 'firebase-functions';
import * as firebaseAdmin from 'firebase-admin';
import * as corsLib from 'cors';
import { recoverPersonalSignature } from '@metamask/eth-sig-util';

const admin = firebaseAdmin.initializeApp();

const cors = corsLib({
  origin: true,
});

export const getNonceToSign = functions.https.onRequest((request, response) =>
  cors(request, response, async () => {
    try {
      if (request.method !== 'POST') {
        return response.sendStatus(403);
      }

      if (!request.body.address) {
        return response.sendStatus(400);
      }

      // Rest of function
    } catch (err) {
      console.log(err);
      return response.sendStatus(500);
    }
  })
);

export const verifySignedMessage = functions.https.onRequest(
  (request, response) =>
    cors(request, response, async () => {
      try {
        if (request.method !== 'POST') {
          return response.sendStatus(403);
        }

        if (!request.body.address || !request.body.signature) {
          return response.sendStatus(400);
        }

        const address = request.body.address;
        const sig = request.body.signature;

        // Rest of function

      } catch (err) {
        console.log(err);
        return response.sendStatus(500);
      }
    })
);

const toHex = (stringToConvert: string) =>
  stringToConvert
    .split('')
    .map((c) => c.charCodeAt(0).toString(16).padStart(2, '0'))
    .join('');

This is just mostly boilerplate for a normal Firebase cloud function, with a few specific things going on. We make sure to initialise a Firebase admin app:

const admin = firebaseAdmin.initializeApp();

This will allow us to make modifications to our Firebase project including create a new user and adding data to the users collection in Firestore. Then we have our two cloud functions which we are expecting POST requests to. This is what each function is expecting:

  • getNonceToSign - an address which represents the Ethereum address the user is trying to sign in with
  • verifySignedMessage - the address and a signature which will be the nonce signed with the user's private key for the Ethereum address

Again, we also have our toHex helper method here as well. Now let's look at the full implementation details. First for the getNonceToSign method:

export const getNonceToSign = functions.https.onRequest((request, response) =>
  cors(request, response, async () => {
    try {
      if (request.method !== 'POST') {
        return response.sendStatus(403);
      }

      if (!request.body.address) {
        return response.sendStatus(400);
      }

      // Get the user document for that address
      const userDoc = await admin
        .firestore()
        .collection('users')
        .doc(request.body.address)
        .get();

      if (userDoc.exists) {
        // The user document exists already, so just return the nonce
        const existingNonce = userDoc.data()?.nonce;
        return response.status(200).json({ nonce: existingNonce });
      } else {
        // The user document does not exist, create it first
        const generatedNonce = Math.floor(Math.random() * 1000000).toString();

        // Create an Auth user
        const createdUser = await admin.auth().createUser({
          uid: request.body.address,
        });

        // Associate the nonce with that user
        await admin.firestore().collection('users').doc(createdUser.uid).set({
          nonce: generatedNonce,
        });

        return response.status(200).json({ nonce: generatedNonce });
      }
    } catch (err) {
      console.log(err);
      return response.sendStatus(500);
    }
  })
);

The first thing we do here is to get the document from the users collection which has an id that matches the Ethereum address that the user is trying to sign in with. If that document exists, then we just return the nonce for that user.

If the document does not exist then we will:

  1. Generate a new nonce
  2. Create a new user with Firebase (using the Ethereum address as that users uid)
  3. Create a document in the users collection with an id matching the uid and add the generated nonce to it
  4. Send the nonce back to the user

An important thing to note here is that anybody can trigger the creation of an account just by supplying the Ethereum address (they don't need to have the private key). However, they will never be able to log in with it unless they do have the private key.

Now let's look at the verifySignedMessage function:

export const verifySignedMessage = functions.https.onRequest(
  (request, response) =>
    cors(request, response, async () => {
      try {
        if (request.method !== 'POST') {
          return response.sendStatus(403);
        }

        if (!request.body.address || !request.body.signature) {
          return response.sendStatus(400);
        }

        const address = request.body.address;
        const sig = request.body.signature;

        // Get the nonce for this address
        const userDocRef = admin.firestore().collection('users').doc(address);
        const userDoc = await userDocRef.get();

        if (userDoc.exists) {
          const existingNonce = userDoc.data()?.nonce;

          // Recover the address of the account used to create the given Ethereum signature.
          const recoveredAddress = recoverPersonalSignature({
            data: `0x${toHex(existingNonce)}`,
            signature: sig,
          });

          // See if that matches the address the user is claiming the signature is from
          if (recoveredAddress === address) {
            // The signature was verified - update the nonce to prevent replay attacks
            // update nonce
            await userDocRef.update({
              nonce: Math.floor(Math.random() * 1000000).toString(),
            });

            // Create a custom token for the specified address
            const firebaseToken = await admin.auth().createCustomToken(address);

            // Return the token
            return response.status(200).json({ token: firebaseToken });
          } else {
            // The signature could not be verified
            return response.sendStatus(401);
          }
        } else {
          console.log('user doc does not exist');
          return response.sendStatus(500);
        }
      } catch (err) {
        console.log(err);
        return response.sendStatus(500);
      }
    })
);

With this function we once again retrieve the nonce for the appropriate account from the users collection. We then use the recoverPersonalSignature method from eth-sig-util to verify the signature. Supplying this method with the data that was to be signed, and the resulting signature of that signed data, we can determine what address was actually used to sign that data.

If the address that the data was signed with matches the address that the user is trying to log in with, then we can consider them to have successfully authenticated.

At this stage we make sure to update the nonce in the users collection (to prevent replay attacks), we create a custom token with Firebase using the supplied address (which will match the uid of the user we want to sign in), and then we return the token to the user.

And we're all done! You should now be able to log a user in with MetaMask and they will be authenticated with Firebase as if they had used any other supported sign in method.

Summary

Now there isn't anything specifically "special" or new about this process. We aren't actually using the Ethereum blockchain or any other blockchain, we aren't using crypto currencies, and technically we don't even need MetaMask. We could always have created an authentication process using a private/public key pair.

The reason this, and specifically MetaMask, is interesting to me is that is makes the process of using a private key much more accessible. We are still very much in the early adopter stages, but if it does continue to become more normal for people to have a crypto wallet extension like MetaMask installed in their browser, then authentication with a public/private key pair becomes as trivial and user friendly as existing social log in methods.

The big difference here being that the user completely owns and controls their identity/access rather than a 3rd party provider. A user isn't tied to MetaMask, they can always just move their private key elsewhere if they want to (or they could even manage their key themselves).