Skip to content

Sign-In with Ethereum

Sign-In with Ethereum is an authentication standard (EIP-4361) that enables secure communication between a frontend and backend. SIWE is a powerful method for creating user sessions based on a wallet connection, and much more!

The example below builds on the Connect Wallet and Sign Message examples. Try it out before moving on.

Pretty cool, right?! You can refresh the window or disconnect your wallet, and you are still securely logged in.

Overview

Implementing SIWE only takes four steps:

  1. Connect wallet
  2. Sign SIWE message with nonce generated by backend
  3. Verify submitted SIWE message and signature via POST request
  4. Add validated SIWE fields to session (via JWT, cookie, etc.)
💡

This guide uses Next.js API Routes for the backend and iron-session to secure the user session, but you can also use other backend frameworks and storage methods.

Prerequisites

Inside your React project directory, run the following to install siwe and other dependencies:

yarn add siwe iron-session
# or
npm install siwe iron-session

Step 1: Connect Wallet

Follow the Connect Wallet guide to get this set up.

Step 2: Add API routes

First, create an API route for generating a random nonce. This is used to identify the session and prevent against replay attacks.

# pages/api/nonce.ts

import { withIronSessionApiRoute } from 'iron-session/next'
import { NextApiRequest, NextApiResponse } from 'next'
import { generateNonce } from 'siwe'

const handler = (req: NextApiRequest, res: NextApiResponse) => {
  const { method } = req
  switch (method) {
    case 'GET':
      req.session.nonce = generateNonce()
      await req.session.save()
      res.setHeader('Content-Type', 'text/plain')
      res.send(req.session.nonce)
      break
    default:
      res.setHeader('Allow', ['GET'])
      res.status(405).end(`Method ${method} Not Allowed`)
  }
}

export default withIronSessionApiRoute(handler, ironOptions)

Next, add an API route for verifying a SIWE message and creating the user session.

# pages/api/verify.ts

import { withIronSessionApiRoute } from 'iron-session/next'
import { NextApiRequest, NextApiResponse } from 'next'
import { SiweMessage } from 'siwe'

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  const { method } = req
  switch (method) {
    case 'POST':
      try {
        const { message, signature } = req.body
        const siweMessage = new SiweMessage(message)
        const fields = await siweMessage.validate(signature)

        if (fields.nonce !== req.session.nonce)
          return res.status(422).json({ message: 'Invalid nonce.' })

        req.session.siwe = fields
        await req.session.save()
        res.json({ ok: true })
      } catch (_error) {
        res.json({ ok: false })
      }
      break
    default:
      res.setHeader('Allow', ['POST'])
      res.status(405).end(`Method ${method} Not Allowed`)
  }
}

export default withIronSessionApiRoute(handler, ironOptions)

ironOptions should look something like this:

{
  cookieName: 'siwe',
  password: 'complex_password_at_least_32_characters_long',
  cookieOptions: {
    secure: process.env.NODE_ENV === 'production',
  },
}

Finally, add two simple API routes for retrieving the signed in user:

# pages/api/me.ts

import { withIronSessionApiRoute } from 'iron-session/next'
import { NextApiRequest, NextApiResponse } from 'next'

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  const { method } = req
  switch (method) {
    case 'GET':
      res.send({ address: req.session.siwe?.address })
      break
    default:
      res.setHeader('Allow', ['GET'])
      res.status(405).end(`Method ${method} Not Allowed`)
  }
}

export default withIronSessionApiRoute(handler, ironOptions)

And logging out:

# pages/api/logout.ts

import { withIronSessionApiRoute } from 'iron-session/next'
import { NextApiRequest, NextApiResponse } from 'next'

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  const { method } = req
  switch (method) {
    case 'GET':
      req.session.destroy()
      res.send({ ok: true })
      break
    default:
      res.setHeader('Allow', ['GET'])
      res.status(405).end(`Method ${method} Not Allowed`)
  }
}

export default withIronSessionApiRoute(handler, ironOptions)

Step 3: Sign & Verify Message

Now that the connect wallet logic and API routes are set up, we can sign in the user! We'll create a new SiweMessage and sign it using the useSignMessage hook. We can also add a log out button and an effect for fetching the logged in user when the page loads or window gains focus.

import * as React from 'react'
import { useAccount, useNetwork, useSignMessage } from 'wagmi'
import { SiweMessage } from 'siwe';

export const Example = () => {
  const [{ data: accountData }] = useAccount()
  const [{ data: networkData }] = useNetwork()

  const [state, setState] = React.useState<{
    address?: string
    error?: Error
    loading?: boolean
  }>({})
  const [, signMessage] = useSignMessage()

  const signIn = React.useCallback(async () => {
    try {
      const address = accountData?.address
      const chainId = networkData?.chain?.id
      if (!address || !chainId) return

      setState((x) => ({ ...x, error: undefined, loading: true }))
      // Fetch random nonce, create SIWE message, and sign with wallet
      const nonceRes = await fetch('/api/nonce')
      const message = new SiweMessage({
        domain: window.location.host,
        address,
        statement: 'Sign in with Ethereum to the app.',
        uri: window.location.origin,
        version: '1',
        chainId,
        nonce: await nonceRes.text(),
      })
      const signRes = await signMessage({ message: message.prepareMessage() })
      if (signRes.error) throw signRes.error

      // Verify signature
      const verifyRes = await fetch('/api/verify', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ message, signature: signRes.data }),
      })
      if (!verifyRes.ok) throw new Error('Error verifying message')

      setState((x) => ({ ...x, address, loading: false }))
    } catch (error) {
      setState((x) => ({ ...x, error, loading: false }))
    }
  }, [])

  // Fetch user when:
  React.useEffect(() => {
    const handler = async () => {
      try {
        const res = await fetch('/api/me')
        const json = await res.json()
        setState((x) => ({ ...x, address: json.address }))
      } finally {
        setState((x) => ({ ...x, loading: false }))
      }
    }
    // 1. page loads
    ;(async () => await handler())()

    // 2. window is focused (in case user logs out of another window)
    window.addEventListener('focus', handler)
    return () => window.removeEventListener('focus', handler)
  }, [])

  if (accountData) {
    return (
      <div>
        {/** Display connected wallet info here */}
        {/** ... */}

        {state.address ? (
          <div>
            <div>Signed in as {state.address}</div>
            <button
              onClick={async () => {
                await fetch('/api/logout')
                setState({})
              }}
            >
              Sign Out
            </button>
          </div>
        ) : (
          <button disabled={state.loading} onClick={signIn}>
            Sign-In with Ethereum
          </button>
        )}
      </div>
    )
  }

  return ...
}

Wrap Up

That's it! You now have a way for users to securely sign in to an app using Ethereum wallets. You can start building rich web apps that use persistent user sessions while still letting users control their login identity (and so much more). Check out the Sign-In with Ethereum website for more info.