Docs · Crypto

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):

  1. 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.
  2. 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.