DEV Community

Cover image for Finally authenticating Rails users with MetaMask
Afri
Afri

Posted on • Updated on

Finally authenticating Rails users with MetaMask

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. 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.)

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 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. In addition, for blockchains such as Ethereum, there are extensions like MetaMask.

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.

rails new myapp
cd myapp
Enter fullscreen mode Exit fullscreen mode

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

class User < ApplicationRecord
  validates :eth_address, presence: true, uniqueness: true
  validates :eth_nonce, presence: true, uniqueness: true
  validates :username, presence: true, uniqueness: true
end
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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 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.

<%= 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" %>
Enter fullscreen mode Exit fullscreen mode

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.

// 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();
  });
}
Enter fullscreen mode Exit fullscreen mode

The JavaScript contains the following logic:

  • It ensures an Ethereum context is available.
  • 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.

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
Enter fullscreen mode Exit fullscreen mode

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.

<%= 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" %>
Enter fullscreen mode Exit fullscreen mode

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.

// 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();
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

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.

// 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;
}
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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.

// 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;
}
Enter fullscreen mode Exit fullscreen mode

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

bundle install
bin/rails db:migrate
bin/rails server
Enter fullscreen mode Exit fullscreen mode

What did I just read?

To recap, an Ethereum account is a public-private key pair (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 tries to standardize the message signed by the user. Check out the Sign-in with Ethereum (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 for production.

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

Further resources

Discussion (3)

Collapse
ltfschoen profile image
Luke Schoen

it mentions that in the login form you can hide the field values that are generated address, custom message (including the website title, the user's nonce, and the current timestamp), and signature since JavaScript can handle the content, and that the user will only interact with the button and the browser extension, but if you did that then the user may not know what they're signing, and whilst i think it's now possible to view the custom message in the browser extension like MetaMask when they're actually signing it with their Ethereum account, it may not be clear what those values represent when they appear on the MetaMask page where they're prompted to sign, so perhaps it's better to first display and explain what the custom message contains on the frontend page itself so the users understand, or if it's possible to provide information about each part of the custom message to MetaMask when they click to login and update the MetaMask codebase so the user can toggle a view that explains more information about what parts of the custom message mean within the MetaMask signature windows prompt

Collapse
abigoroth profile image
Ahmad Ya'kob Ubaidullah

hi.. I'm having problem with android metamask to access this kind of setup.
android metamask will not hold the session.

Collapse
ltfschoen profile image
Luke Schoen

what is a "nym"?
That word is mentioned a couple of times as it relates to the username but I don't understand what it means or whether it's an abbreviation for something