🦊MetaMask Authentication

Sign in with MetaMask (SIWE) using OmniAuth OpenID Connect

This guide adds “Sign in with MetaMask” (Sign-In with Ethereum, SIWE) to your Lightning Rails app using Devise + OmniAuth OpenID Connect. It keeps your regular Devise auth and adds a wallet-based login. No private keys ever touch your server—users only sign a message.

You’ll:

  • Install and configure OmniAuth OIDC

  • Register an OIDC client for development and production

  • Add a Devise callback controller

  • Store the user’s wallet address and show it in the navbar

1) Add gems

# Gemfile
gem "omniauth"
gem "omniauth-rails_csrf_protection"
gem "omniauth_openid_connect", ">= 0.8.0"
bundle install

2) Add User fields

Generate and run the migration to store the wallet address and a timestamp:

rails g migration AddWeb3ToUsers wallet_address:string last_siwe_at:datetime
bin/rails db:migrate

If you prefer the exact file from the PR:

# db/migrate/xxxxxx_add_web3_to_users.rb
class AddWeb3ToUsers < ActiveRecord::Migration[8.0]
  def change
    add_column :users, :wallet_address, :string
    add_index  :users, :wallet_address
    add_column :users, :last_siwe_at, :datetime
  end
end

3) Make your Devise model omniauthable

# app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :omniauthable, omniauth_providers: [:openid_connect]
end

We use the provider name :openid_connect to match routes and callback.


4) OmniAuth configuration

Create the OmniAuth initializer and allow POST/GET (GET helps during local testing; you can lock to POST later).

# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
  provider :openid_connect,
    name: :openid_connect,                        # Devise works best with this name
    scope: %i[openid profile],
    response_type: :code,
    issuer: ENV.fetch("SIWE_ISSUER"),             # e.g. https://oidc.login.xyz/
    discovery: true,
    client_options: {
      identifier:   ENV.fetch("SIWE_CLIENT_ID"),
      secret:       ENV.fetch("SIWE_CLIENT_SECRET"),
      redirect_uri: ENV.fetch("SIWE_REDIRECT_URI")
    }
end
# config/initializers/omniauth_protection.rb
OmniAuth.config.allowed_request_methods = %i[post get]

5) Devise routes

# config/routes.rb
devise_for :users, controllers: { omniauth_callbacks: "users/omniauth_callbacks" }

This generates:

  • Authorize: /users/auth/openid_connect

  • Callback: /users/auth/openid_connect/callback


6) OmniAuth callbacks controller

Generate the controller:

rails g controller users/omniauth_callbacks

Replace its content with:

# app/controllers/users/omniauth_callbacks_controller.rb
module Users
  class OmniauthCallbacksController < Devise::OmniauthCallbacksController
    def openid_connect
      auth  = request.env["omniauth.auth"] || {}
      info  = auth["info"]  || {}
      extra = auth["extra"] || {}
      raw   = (extra["raw_info"] || {}) # userinfo claims

      # Common places SIWE providers put the address (varies by issuer)
      addr = (info["address"] ||
              raw["address"] ||
              raw["wallet_address"] ||
              auth["uid"]).to_s.downcase

      if addr.blank?
        Rails.logger.warn("SIWE: missing address. auth=#{auth.inspect}")
        redirect_to new_user_session_path, alert: "Wallet address missing from SIWE response"
        return
      end

      user = User.find_or_initialize_by(wallet_address: addr)
      if user.new_record?
        user.email    = "eth-#{addr}@example.invalid"    # or prompt later
        user.password = Devise.friendly_token[0, 32]
      end
      user.last_siwe_at = Time.current
      user.save!

      sign_in_and_redirect user, event: :authentication
      set_flash_message(:notice, :success, kind: "MetaMask") if is_navigational_format?
    rescue => e
      Rails.logger.error("SIWE callback error: #{e.class} #{e.message}")
      redirect_to new_user_session_path, alert: "Sign-in failed"
    end

    def failure
      redirect_to new_user_session_path, alert: "Sign-in canceled"
    end
  end
end

7) Add the “Sign in with MetaMask” button

On your Devise sessions page (Lightning Rails’ sign-in):

# app/views/devise/sessions/new.html.erb
<%= button_to user_openid_connect_omniauth_authorize_path,
      method: :post,
      data: { turbo: false },
      class: "btn btn-primary gap-2 mt-4" do %>
  <!-- MetaMask logo (optional SVG) -->
  <svg ... class="w-6 h-6">...</svg>
  Sign in with MetaMask
<% end %>

OmniAuth 2 prefers POST for the authorize request; using button_to handles that.


8) Show the wallet in the navbar (optional)

Helper:

# app/helpers/application_helper.rb
module ApplicationHelper
  def truncate_address(address)
    return "" unless address.present?
    core = extract_core_address(address)
    "0x...#{core[-4..]}"
  end

  def extract_core_address(address)
    return "" unless address.present?

    # Handle eip155:1:0xabc...
    if address.include?(":")
      parts = address.split(":")
      return parts.last if parts.last.start_with?("0x")
    end
    address.start_with?("0x") ? address : "0x#{address}"
  end
end

Navbar snippet:

# app/views/shared/_navbar.html.erb (or your layout’s nav)
<% if user_signed_in? && current_user.wallet_address.present? %>
  <%= truncate_address(current_user.wallet_address) %>
<% else %>
  <%= link_to "Get started", new_user_registration_path, class: "btn btn-primary" %>
<% end %>

9) Environment variables (.env)

Lightning Rails already loads .env. Add these:

# Development issuer (example: login.xyz)
SIWE_ISSUER=https://oidc.login.xyz/
SIWE_CLIENT_ID=your_dev_client_id
SIWE_CLIENT_SECRET=your_dev_client_secret
SIWE_REDIRECT_URI=http://localhost:3000/users/auth/openid_connect/callback

Important: the redirect path must match your provider name :openid_connect. If you change the provider name to :siwe, use /users/auth/siwe/callback and update all references.


10) Register your OIDC client

You need a client per environment (dev and prod). Most SIWE OIDC issuers support dynamic registration.

Development registration (example)

curl -X POST 'https://oidc.login.xyz/register' \
  -H 'Content-Type: application/json' \
  --data '{
    "redirect_uris": ["http://localhost:3000/users/auth/openid_connect/callback"]
  }'

You’ll receive JSON with client_id and client_secret. Put them in your dev .env.

Production registration

Use your production domain:

curl -X POST 'https://oidc.login.xyz/register' \
  -H 'Content-Type: application/json' \
  --data '{
    "redirect_uris": ["https://YOUR_DOMAIN.com/users/auth/openid_connect/callback"]
  }'

Add the prod values to your production environment (e.g., Render/Heroku/Fly secrets) as:

SIWE_ISSUER=https://oidc.login.xyz/
SIWE_CLIENT_ID=your_prod_client_id
SIWE_CLIENT_SECRET=your_prod_client_secret
SIWE_REDIRECT_URI=https://YOUR_DOMAIN.com/users/auth/openid_connect/callback

If you use another issuer (e.g., https://oidc.signinwithethereum.org/), the steps are identical—just change SIWE_ISSUER and the registration URL.


11) Sanity checks

After you’ve set everything:

rails routes | grep openid_connect

You should see:

user_openid_connect_omniauth_authorize  /users/auth/openid_connect(.:format)
user_openid_connect_omniauth_callback   /users/auth/openid_connect/callback(.:format)

Common fixes:

  • Routing error for /auth/...Your Devise routes live under /users/auth/.... Make sure the registered redirect URI matches exactly.

  • “Mapping omniauth_callbacks on a resource that is not omniauthable”: ensure :omniauthable and omniauth_providers: [:openid_connect] are in User.

  • Path helper not found: ensure the provider name matches in the initializer, model, controller method, and the button helper.


12) What you have now

  • Users can sign in with MetaMask (SIWE) via a trusted OIDC provider.

  • You store their wallet address on the users table.

  • You can display it in the UI and later fetch balances/holdings.

From here, you can add:

  • A “Link/Unlink wallet” page

  • ETH balance fetch on the dashboard

  • Parallel Solana login (Phantom) if you want multi-chain auth

That’s it—SIWE is live in your Lightning Rails app.

Last updated

Was this helpful?