Skip to content
SolRengine

solrengine-auth

Sign In With Solana for Rails. Authenticate with any Wallet Standard wallet (Phantom, Solflare, Backpack, Jupiter) — no passwords, no emails.

Solana wallet authentication for Ruby on Rails. Sign in with any Wallet Standard compatible wallet using SIWS (Sign In With Solana). The wallet is the identity — no passwords, no email verification, no OAuth provider.

It’s a Rails engine: a generator wires the model, controller helpers, routes, and a Stimulus controller into your app, and you mount it at /auth. Signature verification is pure Ruby (Ed25519) — no Node sidecar, no external service.

Install

# Gemfile
gem "solrengine-auth"
rails generate solrengine:auth:install
rails db:prepare

The generator (Solrengine::Auth::InstallGenerator) makes five changes:

Change File
Adds wallet_address, nonce, nonce_expires_at columns (creates users if absent) db/migrate/*_create_users_with_wallet_auth.rb
Includes the Authenticatable concern in your model app/models/user.rb
Includes ControllerHelpers (current_user, logged_in?, authenticate!) app/controllers/application_controller.rb
Mounts the engine config/routes.rbmount Solrengine::Auth::Engine, at: "/auth"
Writes the config initializer config/initializers/solrengine_auth.rb

The migration is idempotent — if you already have a users table it adds only the missing columns, so it’s safe to run against an existing model.

Setup

Install the wallet JavaScript dependencies:

yarn add @wallet-standard/app @solana/wallet-standard-features

Register the bundled Stimulus controller in app/javascript/controllers/index.js:

import WalletController from "solrengine/auth/wallet_controller"
application.register("wallet", WalletController)

That’s the whole front end — the controller renders nothing itself; it drives the generated login view. Visit /auth/login and you have a working wallet sign-in.

The SolRengine starter and template-app register the equivalent controller from the @solrengine/wallet-utils npm package instead, which bundles the same wallet flow alongside transaction and clipboard helpers. Either controller speaks the same two endpoints.

Configuration

Every option, with its default:

# config/initializers/solrengine_auth.rb
Solrengine::Auth.configure do |config|
  # The domain that appears in the SIWS message and is verified on the server.
  # MUST match the domain the browser is on, or verification fails by design.
  config.domain = ENV.fetch("APP_DOMAIN", "localhost")

  # How long an issued challenge stays valid.
  config.nonce_ttl = 5.minutes

  # Where the wallet controller sends the user after a successful sign-in.
  config.after_sign_in_path = "/"

  # Where #destroy redirects after sign-out.
  config.after_sign_out_path = "/"

  # The model that backs authentication (String or Class).
  # config.user_class = "User"

  # Chain ID shown in the signed message. Defaults to ENV["SOLANA_NETWORK"]
  # or "mainnet".
  # config.chain_id = "devnet"
end

config.domain is the security boundary, not cosmetics — the verifier rejects any signed message whose first line doesn’t match it exactly (see Security model below).

The sign-in flow, end to end

This is the whole request lifecycle, from button click to authenticated session.

Browser (Stimulus)                Rails (engine)                 Wallet
─────────────────────────────────────────────────────────────────────────
1. GET /auth/login  ───────────▶  renders connect view
2. click "Connect"  ───────────────────────────────────────▶  connect()
                                                               └▶ publicKey
3. GET /auth/nonce  ───────────▶  validate address format
   ?wallet_address                store nonce in SESSION (no DB write)
                    ◀───────────  { message, nonce }
4. sign(message)    ───────────────────────────────────────▶  signMessage()
                                                               └▶ signature
5. POST /auth/verify ──────────▶  check session nonce (wallet + expiry)
   { wallet_address,              verify Ed25519 signature vs nonce
     message, signature }         find_or_create_by!(wallet_address)  ← first DB write
                                  reset_session; session[:user_id] = id
                    ◀───────────  { success: true, wallet_address }
6. redirect to after_sign_in_path

Two server endpoints carry it — GET /auth/nonce to issue the challenge, POST /auth/verify to complete sign-in:

GET /auth/nonce issues the challenge. It validates the address format, then stores a fresh nonce, the wallet it was issued to, and an expiry in the signed cookie session — it performs no database writes. It returns the SIWS message for the wallet to sign:

localhost wants you to sign in with your Solana account:
7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU

Sign in to localhost

URI: http://localhost:3000
Version: 1
Chain ID: mainnet
Nonce: 6f1b9c0e8a2d4f7b1c3e5a7d9f0b2c4e
Issued At: 2026-06-09T03:19:57Z

POST /auth/verify completes sign-in. It reads the nonce back from the session (rejecting if it’s missing, issued to a different wallet, or expired), verifies the Ed25519 signature against that nonce, and only then creates the user with find_or_create_by!. It calls reset_session — consuming the nonce so it can’t be replayed and rotating the session id against fixation — and sets session[:user_id].

The challenge is side-effect-free. Because the nonce lives in the session and the user row is created only after a valid signature, an unauthenticated request to /auth/nonce never writes to your database. (Since 0.2.2 — earlier versions created the user up front.)

Usage in controllers

The generator includes ControllerHelpers in your ApplicationController, giving you three methods (the first two are also available as view helpers):

class DashboardController < ApplicationController
  before_action :authenticate!   # redirects to /auth/login unless signed in

  def show
    @wallet_address = current_user.wallet_address
  end
end
  • current_user — the signed-in record (memoized find_by(id: session[:user_id])), or nil
  • logged_in?current_user.present?
  • authenticate!redirect_to login_path unless logged_in?

In views:

<% if logged_in? %>
  Connected: <%= current_user.wallet_address.truncate(12) %>
  <%= button_to "Disconnect", solrengine_auth.logout_path, method: :delete %>
<% else %>
  <%= link_to "Sign in", solrengine_auth.login_path %>
<% end %>

Route helpers live under the solrengine_auth engine proxy: login_path, nonce_path, verify_path, logout_path.

Customizing the login screen

The view at /auth/login yields a title and is otherwise a plain wrapper around the wallet controller — override it by creating app/views/solrengine/auth/sessions/new.html.erb in your app (it takes precedence over the engine’s copy). The only requirement is the controller wiring:

<div data-controller="wallet"
     data-wallet-nonce-url-value="<%= solrengine_auth.nonce_path %>"
     data-wallet-verify-url-value="<%= solrengine_auth.verify_path %>"
     data-wallet-dashboard-url-value="<%= Solrengine::Auth.configuration.after_sign_in_path %>">

  <div data-wallet-target="status"></div>
  <div data-wallet-target="walletList"></div>
  <button data-wallet-target="connectBtn" data-action="click->wallet#authenticate">
    Connect Wallet
  </button>
  <div data-wallet-target="signing" class="hidden">Approve in your wallet…</div>
</div>

Set the page title from anywhere in the view with <% content_for :solrengine_auth_title, "Sign in" %>.

What the wallet controller handles for you

The bundled wallet_controller.js is more than a fetch wrapper — it papers over the real-world differences between wallets:

  • Discovery via Wallet Standard (@wallet-standard/app), filtered to wallets that expose the solana:signMessage feature. Newly-registered wallets are picked up live.
  • User-gesture preservation — it calls the legacy provider’s connect() first (Phantom/Solflare/Backpack), because Chrome only opens an extension popup inside a direct click handler. Wallets without a legacy provider fall back to Wallet Standard’s standard:connect.
  • Per-wallet signMessage quirks — Phantom wants an explicit "utf8" encoding argument; others don’t. Return shapes differ ({ signature } vs raw Uint8Array). The controller normalizes all of them to bytes.
  • CSRF — it reads the csrf-token meta tag and sends it as X-CSRF-Token on both POSTs, so the flow works with Rails’ default forgery protection.

Security model

Property How it’s enforced
Signature authenticity SiwsVerifier decodes the base58 public key and verifies the Ed25519 signature over the exact message bytes (ed25519 gem).
Domain binding The first line must equal "<domain> wants you to sign in with your Solana account:". An exact match rejects both foreign domains and prefix attacks like localhost.evil.com.
Replay resistance The signed nonce is matched against the server’s session nonce with secure_compare. A captured (message, signature) pair is useless once the nonce is consumed.
Single-use nonce reset_session on success clears the nonce; the next verify with the same pair fails as nonce_expired.
Expiry The session nonce carries an expiry (nonce_ttl, default 5 min).
Session fixation reset_session rotates the session id before user_id is set.
Abuse limits #nonce is rate-limited by wallet_address (no per-target nonce churn across rotating IPs); #verify by IP.
No premature writes The user row is created only after signature verification, so the unauthenticated #nonce endpoint can’t be used to seed junk records.

Errors are deliberately generic — #verify returns "Could not sign in" with a machine-readable code (nonce_expired, verification_failed) rather than telling an attacker which check failed.

Standalone verifier

The verifier works without the engine — use it to authenticate requests in an API, a job, or a non-Rails service:

require "solrengine/auth"

verifier = Solrengine::Auth::SiwsVerifier.new(
  wallet_address: "7xKX…AsU",
  message: siws_message,
  signature: signature,        # base64, or comma-joined bytes
  domain: "myapp.com",
  expected_nonce: stored_nonce # bind to a server-issued nonce (recommended)
)

verifier.verify   # => true / false
verifier.verify!  # => true, or raises Solrengine::Auth::SiwsVerifier::VerificationError

Always pass expected_nonce: when verifying a live sign-in. Without it the verifier accepts any syntactically-valid nonce in the signed message, which makes a captured (message, signature) pair replayable while the wallet still has a fresh nonce. The bundled SessionsController always binds the nonce; a hand-rolled verifier must do the same.

Build the canonical message with Solrengine::Auth::SiwsMessageBuilder:

message = Solrengine::Auth::SiwsMessageBuilder.new(
  domain: "myapp.com",
  wallet_address: wallet_address,
  nonce: stored_nonce,
  uri: "https://myapp.com"
).build

License

MIT