Sign in with Ethereum (SIWE)

Lipafy accepts EVM wallet signatures as a sign-in mechanism in addition to Google OAuth. The same wallet can later be used to sign approvals and intent mandates, so wallet sign-in is the cleanest path for crypto-native users who want one identity across all three.

The implementation follows EIP-4361 (Sign-In with Ethereum): server issues a nonce, client signs a structured message including the nonce, server verifies the signature against the claimed address.

Why a signature, not a password

  • No password to leak — the wallet’s private key never leaves the user’s device.
  • One identity for sign-in + approvals + mandates — the same key that authorizes a 5000 KES purchase via wallet signature is the one that signed you in.
  • Multi-device by default — the wallet on your phone can authorize what the wallet on your laptop kicked off, because they’re both the same key.

The flow

1

Get a nonce

1POST /v1/auth/wallet/nonce
2Content-Type: application/json
3
4{
5 "address": "0xabc123...",
6 "chain_id": 1
7}

Returns:

1{
2 "nonce": "f9e8d7c6b5a4...",
3 "domain": "app.lipafy.xyz",
4 "uri": "https://app.lipafy.xyz",
5 "version": "1",
6 "issued_at": "2026-05-17T09:00:00Z",
7 "expires_at": "2026-05-17T09:10:00Z"
8}

The nonce is a server-side random value with a 10-minute TTL. Single-use — once verified, it’s burned.

2

Build the SIWE message

Standard EIP-4361 format:

app.lipafy.xyz wants you to sign in with your Ethereum account:
0xabc123...
Sign in to Lipafy.
URI: https://app.lipafy.xyz
Version: 1
Chain ID: 1
Nonce: f9e8d7c6b5a4...
Issued At: 2026-05-17T09:00:00Z
Expiration Time: 2026-05-17T09:10:00Z

Wallets render this as a human-readable prompt rather than opaque hex — that’s the whole point of EIP-4361.

3

Have the wallet sign it

Using viem:

1import { createWalletClient, custom } from 'viem'
2import { mainnet } from 'viem/chains'
3
4const client = createWalletClient({
5 chain: mainnet,
6 transport: custom(window.ethereum),
7})
8
9const [address] = await client.getAddresses()
10const signature = await client.signMessage({
11 account: address,
12 message,
13})

MetaMask, Rabby, Brave Wallet, and most EIP-1193 providers handle this transparently. For multi-wallet pages (multiple injected providers), use window.ethereum.providers if available — see Multi-provider handling below.

4

Submit the signature

1POST /v1/auth/wallet/verify
2Content-Type: application/json
3
4{
5 "message": "<the full SIWE message from step 2>",
6 "signature": "0xdeadbeef..."
7}

Lipafy:

  1. Parses the SIWE message and extracts address, nonce, chain_id, expires_at.
  2. Looks up the nonce; checks it hasn’t been used and hasn’t expired.
  3. Verifies the signature recovers to the claimed address (ECDSA, secp256k1).
  4. Marks the nonce as used.
  5. Looks up the account by linked wallet address. If none, creates a new account with account_type='developer'.
  6. Issues a session cookie (Set-Cookie: lipafy_session=...) and returns a JWT for non-browser clients.

Response:

1{
2 "account": { "id": "...", "type": "developer", "status": "active" },
3 "address": "0xabc123...",
4 "chain_id": 1,
5 "is_new_account": false,
6 "token": "eyJhbGc..."
7}

What binds a wallet to an account

The account_wallets table — one address can be linked to one account. The first time someone signs in with a wallet, Lipafy:

  1. Creates an account.
  2. Links the wallet address.
  3. Adds the address as a signing key for that account (so subsequent approvals signed by it are valid).

Adding more wallets to an existing account is available from the dashboard’s Account page. Sign in first, then use the wallet-linking action so approvals and mandates can be signed by that wallet.

Multi-provider handling

Modern browsers often have multiple injected providers (MetaMask + Rabby + Brave Wallet, etc.). window.ethereum is whichever loaded last, which may not be the one the user wants. Detect and let the user pick:

1function listProviders(): WalletProvider[] {
2 const eth = (window as any).ethereum
3 if (!eth) return []
4 if (Array.isArray(eth.providers) && eth.providers.length) {
5 return eth.providers
6 }
7 return [eth]
8}
9
10const providers = listProviders()
11// Present to user; user picks one; call its request('eth_requestAccounts') etc.

EIP-6963 (the newer “discover wallets via window events” pattern) is also worth supporting if you’re shipping a wallet-heavy frontend.

User rejection

If the user dismisses the wallet prompt:

  • MetaMask throws with code: 4001.
  • Other wallets typically use the same code (it’s the standard EIP-1193 user-rejected error).
  • viem surfaces this as UserRejectedRequestError.

Catch and surface a clean “you cancelled” rather than a stack trace:

1try {
2 await client.signMessage({ account: address, message })
3} catch (err: any) {
4 if (err.code === 4001 || err.name === 'UserRejectedRequestError') {
5 return alert('Sign-in cancelled')
6 }
7 throw err
8}

Chain ID

The chain_id in the SIWE message is a binding — a signature valid on chain 1 is not valid on chain 137 (because the message includes the chain id literal). Lipafy currently records the chain id but doesn’t enforce a specific one for sign-in. For approvals and mandates, the chain id is similarly recorded; we may add per-account-allowed-chains in future for replay protection across chains.

Errors

errorCause
wallet_address_invalidaddress isn’t a 40-character hex string starting with 0x
wallet_nonce_invalidNonce is unknown, already used, or expired
wallet_signature_invalidSignature doesn’t recover to the claimed address

Status code is 401 for all three.

Browser sign-in sets lipafy_session=<token>; HttpOnly; SameSite=Lax; Secure; Path=/. The token is hashed and stored in sessions; on every request, Lipafy looks it up by hash, so revoking a session is a single row delete with no token forgery risk even if the DB leaks.

Sign out: POST /v1/auth/logout (this session) or POST /v1/auth/logout/all (every session).

Comparison with Google OAuth

SIWEGoogle
New account creationAutomatic on first signatureAutomatic on first Google return
Linking second auth methodPossible (add a wallet to a Google account, or vice versa)Same
Re-auth for approvalsSame wallet signs approvals nativelyNeed to also link a wallet for high-value approvals; or use approve-by-session for lower-value
Multi-deviceYes — same key on any device worksYes — sign in with Google on each device
Best forCrypto-native users, agents that sign their own mandatesMainstream users, “I just want to log in”

Most users will use Google. SIWE is the right choice when you want one identity across sign-in and on-chain-style authorizations.