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.
- 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.
- 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.
- 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.
- All these bits, the signature, the message, and the user's account are cryptographically verified in the Rails backend.
- 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
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
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
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" %>
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();
});
}
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
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" %>
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();
}
});
}
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;
}
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
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;
}
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
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
- q9f/ethereum-on-rails: All the code from this tutorial in one place.
- EIP-4361: Sign-In with Ethereum: an attempt to standardize the message you sign for authentication.
- Sign-In with Ethereum - Ruby Library and Rails Examples Release: an implementation of EIP-4361 in Ruby-on-Rails.
- One-click Login with Blockchain - A MetaMask Tutorial by my former colleague Amaury Martiny.
Top comments (3)
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
hi.. I'm having problem with android metamask to access this kind of setup.
android metamask will not hold the session.
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