Finally authenticating Rails users with MetaMask
Tags: rails, ethereum, web3, javascript
Published: 2022-03-01 13:54:22

_It's not a secret that passwords are a relic from a different century. However, modern cryptography provides us with far better means to authenticate with applications, such as [Ethereum's Secp256k1 public-private key pairs](https://dev.to/q9/finally-understanding-ethereum-accounts-1kpe). This article is a complete step-by-step deep-dive to securely establish a Ruby-on-Rails user session with an Ethereum account instead of a password. In addition, it aims to explain how it's done by providing code samples and expands on the security implications. (For the impatient, the entire code is available on Github at [`ethereum-on-rails`](https://github.com/q9f/ethereum-on-rails).)_

## Web3 Concepts
This post comes with some technical depth and introduces mechanics that are relatively new concepts and require you to understand some context. However, if you already know what _Web3_ is, scroll down to the next section.

[Web3](https://en.wikipedia.org/wiki/Web3) is a relatively new term that introduces us to a new generation of web applications after Web 1.0 and 2.0. It's beyond the scope of this article to explain the concepts of Web3. However, it's essential to understand that web components and services are no longer hosted on servers. Instead, web applications embed content from decentralized storage solutions, such as IPFS, or consensus protocols, such as Ethereum.

Notably, there are different ways to integrate such components in web applications. However, since the most prominent way to access the web is a _web browser_, most Web3 content can be easily accessed through _browser extensions._ For example, data hosted on IPFS can be retrieved through local or remote nodes using an extension called _[IPFS Companion](https://docs.ipfs.io/install/ipfs-companion/)._ In addition, for blockchains such as Ethereum, there are extensions like _[MetaMask](https://metamask.io/)._

The benefit of such an Ethereum extension is the different ways of accessing blockchain states and the ability for users to manage their Ethereum accounts. And this is what we will utilize for this tutorial: an Ethereum account in a MetaMask browser extension connecting to your Ruby-on-Rails web application to authenticate a user session securely.

## Authentication Process Overview
Before diving in and creating a new Rails app, let's take a look at the components we'll need throughout the tutorial.
1. We need to create a user model that includes fields for the Ethereum address of the user and a random nonce that the user will sign later during authentication for security reasons.
2. We'll create an API endpoint that allows fetching the random nonce for a user's Ethereum address from the backend to be available for signing in the frontend.
3. In the browser, we'll generate a custom message containing the website title, the user's nonce, and a current timestamp that the user has to sign with their browser extension using their Ethereum account.
4. All these bits, the signature, the message, and the user's account are cryptographically verified in the Rails backend.
5. If this succeeds, we'll create a new authenticated user session and rotate the user's nonce to prevent signature spoofing for future logins.

Let's get started.

## Rails' User Model
We'll use a fresh Rails 7 installation without additional modules or custom functionality. Just install Rails and get a new instance according to the docs.

```shell
rails new myapp
cd myapp
```

Create an `app/models/user.rb` first, which will define the bare minimum required for our user model.

```ruby
class User < ApplicationRecord
  validates :eth_address, presence: true, uniqueness: true
  validates :eth_nonce, presence: true, uniqueness: true
  validates :username, presence: true, uniqueness: true
end
```

Note that we no longer care about passwords, email addresses, or other fields. Of course, you may add any arbitrary field you like, but these three fields are essential for an Ethereum authentication:
- The username is a human-friendly string allowing users to identify themself with a nym.
- The user's Ethereum account address is to authenticate with your application.
- The nonce is a random secret in the `user` database schema used to prevent signature spoofing (more on that later).

## User Controller `#create`
The controllers are powerful Rails tools to handle your routes and application logic. Here, we will implement creating new user accounts with an Ethereum address in `app/controllers/users_controller.rb`.

```ruby
require "eth"

def create
  # only proceed with pretty names
  if @user and @user.username and @user.username.size > 0
    # create random nonce
    @user.eth_nonce = SecureRandom.uuid
    # only proceed with eth address
    if @user.eth_address
      # make sure the eth address is valid
      if Eth::Address.new(@user.eth_address).valid?
        # save to database
        if @user.save
          # if user is created, congratulations, send them to login
          redirect_to login_path, notice: "Successfully created an account, you may now log in."
        end
      end
    end
  end
end
```

The `Users` controller is solely used for creating new users.
- It generates an initial random nonce with `SecureRandom.uuid`.
- It ensures the user picks a name.
- It takes the `eth_address` from the sign-up view (more on that later).
- It guarantees the `eth_address` is a valid Ethereum address.
- It creates a new `user` and saves it to the database with the given attributes.

We are using the [`eth`](https://github.com/q9f/eth.rb) gem to validate the address field.

Be aware that we do not require any signature to reduce complexity and increase the accessibility of this tutorial. It is, however, strongly recommended to unify the login and sign-up process to prevent unnecessary spam in the `user` database, i.e., if a user with the given address does not exist, create it.

## Connecting to MetaMask
We already taught our Rails backend what a User object looks like (model) and how to handle logic (controller). However, two components are missing to make this work: a new-user view rendering the sign-up form and some JavaScript to manage the frontend logic.

For the sign-up form, add a `form_for @user` to the `app/views/users/new.html.erb` view.

```erb
<%= form_for @user, url: signup_path do |form| %>
  <%= form.label "Name" %>
  <%= form.text_field :username %> <br />
  <%= form.text_field :eth_address, readonly: true, class: "eth_address" %> <br />
<% end %>
<button class="eth_connect">Sign-up with Ethereum</button>
<%= javascript_pack_tag "users_new" %>
```

We'll allow the user to fill in the `:username` field but make the `:eth_address` field read-only because this will be filled in by the browser extension. We could even add some CSS to hide it.

Lastly, the `eth_connect` button triggers the JavaScript to connect to MetaMask and query the user's Ethereum account. But, first, let's take a look at `app/javascript/packs/users_new.js`.

```js
// the button to connect to an ethereum wallet
const buttonEthConnect = document.querySelector('button.eth_connect');
// the read-only eth address field, we process that automatically
const formInputEthAddress = document.querySelector('input.eth_address');
// get the user form for submission later
const formNewUser = document.querySelector('form.new_user');
// only proceed with ethereum context available
if (typeof window.ethereum !== 'undefined') {
  buttonEthConnect.addEventListener('click', async () => {
    // request accounts from ethereum provider
    const accounts = await ethereum.request({ method: 'eth_requestAccounts' });
    // populate and submit form
    formInputEthAddress.value = accounts[0];
    formNewUser.submit();
  });
}
```

The JavaScript contains the following logic:
- It ensures an [Ethereum context is available](https://docs.metamask.io/guide/ethereum-provider.html#basic-usage).
- It adds a click-event listener to the connect button.
- It requests accounts from the available Ethereum wallet: `method: 'eth_requestAccounts'`
- It adds the `eth_address` to the form and submits it.

Now, we have a Rails application with the basic User logic implemented. But how do we authenticate the users finally?

## User Sessions
The previous sections were an introduction, preparing a Rails application to handle users with the schema we need. Now, we are getting to the core of the authentication: Users are the prerequisite; logging in a user requires a _Session._ Let's take a look at the `app/controllers/sessions_controller.rb`.

```ruby
require "eth"
require "time"

def create
  # users are indexed by eth address here
  user = User.find_by(eth_address: params[:eth_address])
  # if the user with the eth address is on record, proceed
  if user.present?
    # if the user signed the message, proceed
    if params[:eth_signature]
      # the message is random and has to be signed in the ethereum wallet
      message = params[:eth_message]
      signature = params[:eth_signature]
      # note, we use the user address and nonce from our database, not from the form
      user_address = user.eth_address
      user_nonce = user.eth_nonce
      # we embedded the time of the request in the signed message and make sure
      # it's not older than 5 minutes. expired signatures will be rejected.
      custom_title, request_time, signed_nonce = message.split(",")
      request_time = Time.at(request_time.to_f / 1000.0)
      expiry_time = request_time + 300
      # also make sure the parsed request_time is sane
      # (not nil, not 0, not off by orders of magnitude)
      sane_checkpoint = Time.parse "2022-01-01 00:00:00 UTC"
      if request_time and request_time > sane_checkpoint and Time.now < expiry_time
        # enforce that the signed nonce is the one we have on record
        if signed_nonce.eql? user_nonce
          # recover address from signature
          signature_pubkey = Eth::Signature.personal_recover message, signature
          signature_address = Eth::Util.public_key_to_address signature_pubkey
          # if the recovered address matches the user address on record, proceed
          # (uses downcase to ignore checksum mismatch)
          if user_address.downcase.eql? signature_address.to_s.downcase
            # if this is true, the user is cryptographically authenticated!
            session[:user_id] = user.id
            # rotate the random nonce to prevent signature spoofing
            user.eth_nonce = SecureRandom.uuid
            user.save
            # send the logged in user back home
            redirect_to root_path, notice: "Logged in successfully!"
          end
        end
      end
    end
  end
end
```

The controller does the following.
- It finds the user by `eth_address` provided by the Ethereum wallet.
- It ensures the user exists in the database by looking up the address.
- It guarantees the user signed an `eth_message` to authenticate (more on that later).
- It ensures the `eth_signature` field is not expired (older than five minutes).
- It assures the signed `eth_nonce` matches the one in our database.
- It recovers the public key and address from the signature.
- It ensures the recovered address matches the address in the database.
- It logs the user in if all the above is _true._
- If all of the above is _true,_ it rotates a new nonce for future logins.

The code above, the `#create`-session controller, contains all security checks for the backend authentication. To successfully log in, all assessments need to pass.

Now that we have the controller, we still need a view and the frontend JavaScript logic. The view needs the form and the button in `app/views/sessions/new.html.erb`.

```erb
<%= form_tag "/login", class: "new_session" do %>
  <%= text_field_tag :eth_message, "", readonly: true, class: "eth_message" %> <br />
  <%= text_field_tag :eth_address, "", readonly: true, class: "eth_address" %> <br />
  <%= text_field_tag :eth_signature, "", readonly: true, class: "eth_signature" %> <br />
<% end %>
<button class="eth_connect">Login with Ethereum</button>
<%= javascript_pack_tag "sessions_new" %>
```

The login form only contains three read-only fields: address, message, and signature. We can hide them and let  JavaScript handle the content. The user will only interact with the button and the browser extension. So, last but not least, we'll take a look at our frontend logic in `app/javascript/packs/sessions_new.js`.

```js
// the button to connect to an ethereum wallet
const buttonEthConnect = document.querySelector('button.eth_connect');
// the read-only eth fields, we process them automatically
const formInputEthMessage = document.querySelector('input.eth_message');
const formInputEthAddress = document.querySelector('input.eth_address');
const formInputEthSignature = document.querySelector('input.eth_signature');
// get the new session form for submission later
const formNewSession = document.querySelector('form.new_session');
// only proceed with ethereum context available
if (typeof window.ethereum !== 'undefined') {
  buttonEthConnect.addEventListener('click', async () => {
    // request accounts from ethereum provider
    const accounts = await requestAccounts();
    const etherbase = accounts[0];
    // sign a message with current time and nonce from database
    const nonce = await getUuidByAccount(etherbase);
    if (nonce) {
      const customTitle = "Ethereum on Rails";
      const requestTime = new Date().getTime();
      const message = customTitle + "," + requestTime + "," + nonce;
      const signature = await personalSign(etherbase, message);
      // populate and submit form
      formInputEthMessage.value = message;
      formInputEthAddress.value = etherbase;
      formInputEthSignature.value = signature;
      formNewSession.submit();
    }
  });
}
```

That's a lot to digest, so let's look at what the script does, step by step.
- It, again, ensures an Ethereum context is available.
- It adds a click-event listener to the `eth_connect` button.
- It requests accounts from the available Ethereum wallet: `method: 'eth_requestAccounts'`
- It requests the nonce belonging to the account from the API/v1 (more on that later).
- It generates a message containing the site's title, the request time, and the nonce from the API/v1.
- It requests the user to sign the message: `method: 'personal_sign', params: [ message, account ]`
- It populates the form with address, message, and signature and submits it.

Putting aside the API/v1 (for now), we have everything in place: The Rails application crafts a custom message containing a random nonce and a timestamp. Then, the frontend requests the user to sign the payload with their Ethereum account. The following snippet shows the relevant JavaScript for requesting accounts and signing the message.

```js
// request ethereum wallet access and approved accounts[]
async function requestAccounts() {
  const accounts = await ethereum.request({ method: 'eth_requestAccounts' });
  return accounts;
}

// request ethereum signature for message from account
async function personalSign(account, message) {
  const signature = await ethereum.request({ method: 'personal_sign', params: [ message, account ] });
  return signature;
}
```

Once the message is signed, both the message and the signature, along with the Ethereum account's address, get passed to the Rails backend for verification. If all backend checks succeed (see session controller above), we consider the user authenticated.

## Back and forth
Let's quickly recap. We have a user model containing address, nonce, and name for every user of our Rails application. To create a user, we allow the user to pick a nym, ask the browser extension for the user's Ethereum address and roll a random nonce (here: UUID) for the user database. To authenticate, we let the user sign a message containing a custom string (here: site title), the user's nonce, and a timestamp to force the signature to expire. If the signature matches the Ethereum account and nonce on the record and is not expired, we consider the user cryptographically authenticated.

But one thing is missing. So far, both creating a user and authenticating a new session was a one-way operation, passing data from the frontend to the backend for validation. However, to sign the required nonce from the user database, we need a way for the frontend to access the user's nonce. For that, we create a public API endpoint that allows querying the `eth_nonce` from the user database by the `eth_address` key. Let's take a look at `app/controllers/api/v1/users_controller.rb`.

```ruby
require "eth"

class Api::V1::UsersController < ApiController
  # creates a public API that allows fetching the user nonce by address
  def show
    user = nil
    response = nil
    # checks the parameter is a valid eth address
    params_address = Eth::Address.new params[:id]
    if params_address.valid?
      # finds user by valid eth address (downcase to prevent checksum mismatchs)
      user = User.find_by(eth_address: params[:id].downcase)
    end
    # do not expose full user object; just the nonce
    if user and user.id > 0
      response = [eth_nonce: user.eth_nonce]
    end
    # return response if found or nil in case of mismatch
    render json: response
  end
end
```

The `#show` controller gets a user by `eth_address` from the database and returns the `eth_nonce` or `nil` if it does not exist.
- GET `/api/v1/users/${eth_account}`
- It ensures the `eth_account` parameter is a valid Ethereum address to filter out random requests.
- It finds a user in the database by `eth_account` key.
- It returns only the `eth_nonce` as JSON.
- It returns nothing if it fails any of the above steps.

The frontend can use some JavaScript to fetch this during authentication.

```js
// get nonce from /api/v1/users/ by account
async function getUuidByAccount(account) {
  const response = await fetch("/api/v1/users/" + account);
  const nonceJson = await response.json();
  if (!nonceJson) return null;
  const uuid = nonceJson[0].eth_nonce;
  return uuid;
}
```

And that's it. So now we have all pieces in place. Run your Rails application and test it out!

```shell
bundle install
bin/rails db:migrate
bin/rails server
```

## What did I just read?
To recap, an [Ethereum account is a public-private key pair](https://dev.to/q9/finally-understanding-ethereum-accounts-1kpe) (very similar to SSH, OTR, or PGP keys) that can be used to authenticate a user on any web application without any need for an email, a password, or other gimmicks.

Our application identifies the user not by its name but by the public Ethereum address belonging to their account. By cryptographically signing a custom message containing a user secret and a timestamp, the user can prove that they control the Ethereum account belonging to the user on the record.

A valid, not expired signature matching the nonce and address of the user allows us to grant the user access to our Rails application securely.

## Security Considerations
One might wonder, is this secure?

Generally speaking, having an Ethereum account in a browser extension is comparable with a password manager in a browser extension from an operational security standpoint. The password manager fills the login form with your email and password, whereas the Ethereum wallet shares your address and the signature you carefully approved.

From a technical perspective, it's slightly more secure as passwords can be easier compromised than signatures. For example, a website that tricks you into believing they are your bank can very well steal your bank account credentials. This deception is called _phishing_, and once your email and password are compromised, malicious parties can attempt to log in to all websites where they suspect you of having the same credentials.

Phishing Ethereum signatures is also possible, but due to the very limited validity of the signature both in time and scope, it's more involved. The user nonce in the backend gets rotated with each login attempt, making a signature valid only once. By adding a timestamp to the signed message, applications can also reduce attackers' window of opportunity to just a few minutes.

## Isn't there a standard for that?
There is: [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) tries to standardize the message signed by the user. Check out the [Sign-in with Ethereum](https://login.xyz/) (SIWE) project.

This article is considered educational material and does not use the SIWE-libraries to elaborate on more detailed steps and components. However, it's recommended to check out the [Rails SIWE examples](https://github.com/spruceid/siwe-rails-examples) for production.

Does this make sense? Please let me know in the comments! Thanks for reading!

## Further resources
- [q9f/ethereum-on-rails](https://github.com/q9f/ethereum-on-rails): All the code from this tutorial in one place.
- [EIP-4361: Sign-In with Ethereum](https://eips.ethereum.org/EIPS/eip-4361): an attempt to standardize the message you sign for authentication.
- [Sign-In with Ethereum - Ruby Library and Rails Examples Release](https://blog.spruceid.com/sign-in-with-ethereum-ruby-library-release-and-rails-examples/): an implementation of EIP-4361 in Ruby-on-Rails.
- [One-click Login with Blockchain - A MetaMask Tutorial](https://www.toptal.com/ethereum/one-click-login-flows-a-metamask-tutorial) by my former colleague Amaury Martiny.