Introduction to Sign-in with Ethereum

Introduction

To be simple Sign-in with Ethereum (siwe) is an EIP (Ethereum improvement proposal) that aims to provide a way to authenticate users on websites using their Ethereum wallet by signing a message with a standard format.

The EIP is currently under review being discussed in the Ethereum Magicians forum. You can read the full EIP here.

Followin the EIP, the motivation behind siwe is to offers a new self-custodial sign-in option for users who wish to assume more control and responsibility over their own digital identity. And to be also an opportunity to standardize the sign-in workflow and improve interoperability across existing services.

How it works

The siwe workflow is simple and straightforward. The user signs a message with their Ethereum wallet and the website verifies the signature. The message is a standard format that shoud includes the following information:

  • address: the Ethereum address of the user
  • domain: the domain of the website requesting the signature
  • version: the version of the message
  • chainId: the chainId of the network the user is connected to
  • uri: the uri of the website for scoping
  • nonce: a random string to prevent replay attacks
  • issued-at: the timestamp of the message

The message can contain some additionals fields like expiration-time, not-before, request-id, statement, and resources.

How to use it in JS

Let see how it work in practice. We will use the code from the repo (spruceid/siwe-quickstart)[https://github.com/spruceid/siwe-quickstart/tree/main/03_complete_app] to illustrate the point.

Basically on the backend side we have an express server with the express-session middleware to handle the session. The session is used to store the nonce generated before the user sign the message. The nonce is used to prevent replay attacks. The server has two routes :

  • /nonce : to generate and send the nonce to the client
  • /verify : to verify the signature and to update the session with the user's infos

Here's how it look :

import cors from 'cors'
import express from 'express'
import Session from 'express-session'
import { generateNonce, ErrorTypes, SiweMessage } from 'siwe'

const app = express()
app.use(express.json())
app.use(
  cors({
    origin: 'http://localhost:8080',
    credentials: true,
  })
)

app.use(
  Session({
    name: 'siwe-quickstart',
    secret: 'siwe-quickstart-secret',
    resave: true,
    saveUninitialized: true,
    cookie: { secure: false, sameSite: true },
  })
)

app.get('/nonce', async function (req, res) {
  req.session.nonce = generateNonce()
  res.setHeader('Content-Type', 'text/plain')
  res.status(200).send(req.session.nonce)
})

app.post('/verify', async function (req, res) {
  try {
    if (!req.body.message) {
      res
        .status(422)
        .json({ message: 'Expected prepareMessage object as body.' })
      return
    }

    let message = new SiweMessage(req.body.message)
    const fields = await message.validate(req.body.signature)
    if (fields.nonce !== req.session.nonce) {
      console.log(req.session)
      res.status(422).json({
        message: `Invalid nonce.`,
      })
      return
    }
    req.session.siwe = fields
    req.session.cookie.expires = new Date(fields.expirationTime)
    req.session.save(() => res.status(200).end())
  } catch (e) {
    req.session.siwe = null
    req.session.nonce = null
    console.error(e)
    switch (e) {
      case ErrorTypes.EXPIRED_MESSAGE: {
        req.session.save(() => res.status(440).json({ message: e.message }))
        break
      }
      case ErrorTypes.INVALID_SIGNATURE: {
        req.session.save(() => res.status(422).json({ message: e.message }))
        break
      }
      default: {
        req.session.save(() => res.status(500).json({ message: e.message }))
        break
      }
    }
  }
})

On the front-end side the user need to first connect it's wallet to the website. Next we need to generate the message and sign it with the wallet to do this first we need to get the nonce from the server. And after we can send the signature to the server to validate it.

import { ethers } from 'ethers'
import { SiweMessage } from 'siwe'

const domain = window.location.host
const origin = window.location.origin
const provider = new ethers.providers.Web3Provider(window.ethereum)
const signer = provider.getSigner()

const BACKEND_ADDR = 'http://localhost:3000'
async function createSiweMessage(address, statement) {
  const res = await fetch(`${BACKEND_ADDR}/nonce`, {
    credentials: 'include',
  })
  const message = new SiweMessage({
    domain,
    address,
    statement,
    uri: origin,
    version: '1',
    chainId: '1',
    nonce: await res.text(),
  })
  return message.prepareMessage()
}

function connectWallet() {
  provider
    .send('eth_requestAccounts', [])
    .catch(() => console.log('user rejected request'))
}

async function signInWithEthereum() {
  const message = await createSiweMessage(
    await signer.getAddress(),
    'Sign in with Ethereum to the app.'
  )
  const signature = await signer.signMessage(message)

  const res = await fetch(`${BACKEND_ADDR}/verify`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ message, signature }),
    credentials: 'include',
  })
  console.log(await res.text())
}

const connectWalletBtn = document.getElementById('connectWalletBtn')
const siweBtn = document.getElementById('siweBtn')
const infoBtn = document.getElementById('infoBtn')
connectWalletBtn.onclick = connectWallet
siweBtn.onclick = signInWithEthereum
infoBtn.onclick = getInformation

Conclusion

Now you know the basic of siwe and how it works. As the time of writing this post the siwe package is currently is in v2.0 beta and the code is a bit differents from the one I used in this post. But the main idea is the same that's why I didn't go in details about the implementation in this post. I will cover how to use it more details in the nexts posts, we will see how to use it with with next-auth, Rainbowkit.