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.rb → mount Solrengine::Auth::Engine, at: "/auth" |
| Writes the config initializer | config/initializers/solrengine_auth.rb |
The migration is idempotent — if you already have a
userstable 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-appregister the equivalent controller from the@solrengine/wallet-utilsnpm 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/noncenever 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 (memoizedfind_by(id: session[:user_id])), ornillogged_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 thesolana:signMessagefeature. 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’sstandard:connect. - Per-wallet
signMessagequirks — Phantom wants an explicit"utf8"encoding argument; others don’t. Return shapes differ ({ signature }vs rawUint8Array). The controller normalizes all of them to bytes. - CSRF — it reads the
csrf-tokenmeta tag and sends it asX-CSRF-Tokenon 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 bundledSessionsControlleralways 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