Wallet Sign-In
Sign-In With Ethereum (EIP-4361), verified in Ruby with no vendor and no key.
A user connects a wallet, signs a message, and Rails verifies the signature itself. There is no
third party in this path: Onchain::Signature does secp256k1 public-key recovery on Ruby's stdlib
OpenSSL bindings.
Why no gem
Ruby's OpenSSL binding exposes EC::Point#mul(bn1, bn2), which computes bn1 * P + bn2 * G. That
is exactly the ECDSA recovery equation, so no libsecp256k1 binding is needed: no C extension, no
autotools in the Dockerfile, nothing new for a fresh clone to fail on. A full verification measures
around three milliseconds against a five-minute challenge window.
Keccak-256 is hand-written for a sharper reason. OpenSSL ships SHA3-256, which is a different function: NIST changed the padding byte between Keccak's submission and the SHA-3 standard. Substituting it produces confident, wrong addresses with no error at all. A spec asserts the two disagree, so nobody makes that swap later.
Correctness comes from implementations that are not ours. spec/fixtures/onchain/ holds nine
(address, message, signature, digest) quadruples generated by viem, and the address and hash
specs use the published EIP-55 and Keccak vectors. A round trip through our own signing code would
only prove it is self-consistent with itself.
The server builds the message
Rails builds the canonical EIP-4361 message, stores it verbatim in siwe_nonces.message, and
never parses a client-supplied one. Verification re-reads the stored text.
Every SIWE vulnerability to date has lived in a lenient message parser: the grammar is fiddly, and a parser that disagrees with what the wallet displayed is exploitable. Storing the exact signed string deletes that entire class of bug for the cost of one column.
The challenge is also bound to the host it was issued for, and that is re-checked at verification. A challenge minted on one domain cannot be spent on another.
Cold signup has two steps, and both matter
A wallet proves control of an address and nothing else, while this app's accounts are keyed on an email. So a visitor the app has never seen signs once, and then supplies an email in a plain form (not a second wallet popup):
- Signature. A known wallet signs in. A signed-in visitor links the wallet to their existing account. An unknown wallet is recorded as verified, and asked for an email.
- Email. Claimed through
User.upsert_by_email!, the kit's single account-creation chokepoint, so the welcome mail, the Stripe customer and the GDPR export all keep working untouched.
Two guards in step two are load-bearing:
The pending challenge is bound to the browser session. Without that, anyone who observed the nonce could claim the wallet under their own email address.
An email that already has an account is refused, never linked. Signing proves control of the
wallet; it proves nothing about the mailbox. Auto-linking would let anyone holding any wallet seize
any account simply by typing its email. This is the same reasoning as
User::UnverifiedProviderEmail in the OAuth path, and it gets the same explicit refusal.
upsert_by_email! is called with verified: false, so email_verified_at stays nil. Anything in
your app that needs a proven email address still gates on it.
Two limits to know about
One sign-in wallet per user. The identities table is uniquely indexed on (user_id, provider),
which is obviously right for "link Google at most once" and debatable for wallets, where people hold
several. Relaxing it is a schema decision (which address is the identity when any of three can
sign?), so v1 keeps the existing contract and fails with an actionable message rather than a raw
constraint violation.
Smart-contract wallets need a real RPC. Coinbase Smart Wallet and Safe sign via ERC-1271, which requires a contract call to verify. Against the offline fake they cannot sign in. That is a configuration limit rather than a bug, but it presents as an intermittent one if you do not know it.
Trying it
Open /onchain and click Connect. Signing costs no gas and sends no transaction: it is a proof of
control, not a payment.