• Examples
  • Sign-In with Ethereum

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

Install siwe and iron-session with your package manager of choice:

npm install siwe iron-session
iron-session TypeScript Set Up

In order for TypeScript to work properly with iron-session and siwe, you need to add a couple properties to the IronSessionData interface. Add the following to types/iron-session/index.d.ts.

types/iron-session/index.d.ts
import 'iron-session'
import { SiweMessage } from 'siwe'
 
declare module 'iron-session' {
  interface IronSessionData {
    nonce?: string
    siwe?: SiweMessage
  }
}

Then, update your tsconfig.json to include the custom types directory:

tsconfig.json
{
  "compilerOptions": {
    // ...
  },
-  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "types/**/*.d.ts"],
  "exclude": ["node_modules"]
}

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 = async (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.verify({signature})
 
        if (fields.data.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?.data.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)
⚠️

Before going to production, you likely want to invalidate nonces on logout to prevent replay attacks through session duplication (e.g. store expired nonce and make sure they can't be used again).

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 a side 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'
 
function SignInButton({
  onSuccess,
  onError,
}: {
  onSuccess: (args: { address: string }) => void
  onError: (args: { error: Error }) => void
}) {
  const [state, setState] = React.useState<{
    loading?: boolean
    nonce?: string
  }>({})
 
  const fetchNonce = async () => {
    try {
      const nonceRes = await fetch('/api/nonce')
      const nonce = await nonceRes.text()
      setState((x) => ({ ...x, nonce }))
    } catch (error) {
      setState((x) => ({ ...x, error: error as Error }))
    }
  }
 
  // Pre-fetch random nonce when button is rendered
  // to ensure deep linking works for WalletConnect
  // users on iOS when signing the SIWE message
  React.useEffect(() => {
    fetchNonce()
  }, [])
 
  const { address } = useAccount()
  const { chain } = useNetwork()
  const { signMessageAsync } = useSignMessage()
 
  const signIn = async () => {
    try {
      const chainId = chain?.id
      if (!address || !chainId) return
 
      setState((x) => ({ ...x, loading: true }))
      // Create SIWE message with pre-fetched nonce and sign with wallet
      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: state.nonce,
      })
      const signature = await signMessageAsync({
        message: message.prepareMessage(),
      })
 
      // Verify signature
      const verifyRes = await fetch('/api/verify', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ message, signature }),
      })
      if (!verifyRes.ok) throw new Error('Error verifying message')
 
      setState((x) => ({ ...x, loading: false }))
      onSuccess({ address })
    } catch (error) {
      setState((x) => ({ ...x, loading: false, nonce: undefined }))
      onError({ error: error as Error })
      fetchNonce()
    }
  }
 
  return (
    <button disabled={!state.nonce || state.loading} onClick={signIn}>
      Sign-In with Ethereum
    </button>
  )
}
 
export function Profile() {
  const { isConnected } = useAccount()
 
  const [state, setState] = React.useState<{
    address?: string
    error?: Error
    loading?: boolean
  }>({})
 
  // 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 }))
      } catch (_error) {}
    }
    // 1. page loads
    handler()
 
    // 2. window is focused (in case user logs out of another window)
    window.addEventListener('focus', handler)
    return () => window.removeEventListener('focus', handler)
  }, [])
 
  if (isConnected) {
    return (
      <div>
        {/* Account content goes here */}
 
        {state.address ? (
          <div>
            <div>Signed in as {state.address}</div>
            <button
              onClick={async () => {
                await fetch('/api/logout')
                setState({})
              }}
            >
              Sign Out
            </button>
          </div>
        ) : (
          <SignInButton
            onSuccess={({ address }) => setState((x) => ({ ...x, address }))}
            onError={({ error }) => setState((x) => ({ ...x, error }))}
          />
        )}
      </div>
    )
  }
 
  return <div>{/* Connect wallet content goes here */}</div>
}

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.

⚠️

While it might be tempting to combine the "Connect Wallet" and "Sign In" steps into a single action, this causes issues with deep linking for WalletConnect on iOS. This is because the browser doesn't open the corresponding native app if the navigation wasn't immediately triggered by a user action.

Additional Resources