Implement Sign-In with Ethereum (SIWE) Authentication | React + Go Full Example

Learn how to implement Sign-In with Ethereum (SIWE) authentication using React and Go. This guide covers frontend nonce signing, backend signature verification, and JWT token generation — the complete Web3 login flow for integrating Ethereum wallet login into your app.

CChia1104
Notes4 minutes read

siwe Ethereum Login

The frontend first requests a random number (nonce) from the backend.

get nonce

The frontend then uses this nonce to get a signature and a message.

login req

message format

example.com wants you to sign in with your Ethereum account:
12345678

wallet_sign_statement
URI: https://example.com
Version: 1
Chain ID: 1
Nonce: 22ppxlq5
Issued At: 2025-02-19T03:57:48.796Z
Expiration Time: 2025-03-21T03:57:48.796Z

React Example

SignInButton.tsx
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>
}

Backend

The backend needs to decode the message and signature, verify that the nonce matches, and return a token.

login res

main.go
// This is a simple example of how to implement a web3 login endpoint using the siwe-go library.
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"os"
	"time"

	"github.com/ethereum/go-ethereum/common/hexutil"
	"github.com/ethereum/go-ethereum/crypto"
	"github.com/golang-jwt/jwt/v5"
	"github.com/spruceid/siwe-go"
)

const port = ":8080"

func main() {
	http.HandleFunc("/api/v1/auth/web3:login", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
			return
		}
		err := Web3Login(w, r)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}
	})

	err := http.ListenAndServe(port, nil)
	if err != nil {
		fmt.Println("Error starting server:", err)
	}
}

// verifyWalletSignature verifies the signature of a wallet address
func verifyWalletSignature(messageStr string, sig string) (jwt.MapClaims, error) {

	message, err := siwe.ParseMessage(messageStr)
	if err != nil {
		err = fmt.Errorf("parse message err: %v", err)
		return nil, err
	}

	verify, err := message.ValidNow()
	if err != nil {
		err = fmt.Errorf("verify message err: %v", err)
		return nil, err
	}

	if !verify {
		err = fmt.Errorf("verify message fail: %v", err)
		return nil, err
	}

	publicKey, err := message.VerifyEIP191(sig)

	if err != nil {
		err = fmt.Errorf("verifyEIP191 err: %v", err)
		return nil, err
	}

	pubBytes := crypto.FromECDSAPub(publicKey)
	publicKeyString := hexutil.Encode(pubBytes)

	// Return the verified claims
	claims := jwt.MapClaims{
		"web3_pub_key": publicKeyString,
	}
	return claims, nil
}

// LoginRequest represents the login request body
type LoginRequest struct {
	Message   string `json:"message"`
	Signature string `json:"signature"`
}

func Web3Login(w http.ResponseWriter, r *http.Request) error {
	// Parse the login request body
	req := new(LoginRequest)

	// 解析 JSON 請求體
	decoder := json.NewDecoder(r.Body)
	err := decoder.Decode(req)
	if err != nil {
		return errors.New("invalid request body")
	}

	// Verify the signature
	claims, err := verifyWalletSignature(req.Message, req.Signature)
	if err != nil {
		return errors.New(err.Error())
	}

	// Generate a JWT token
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
	if err != nil {
		return errors.New("error creating JWT token")
	}

	// Set the JWT token in the response header
	http.SetCookie(w, &http.Cookie{
		Name:    "jwt_token",
		Value:   tokenString,
		Expires: time.Now().Add(time.Hour * 24),
	})

	// Return a success response
	w.WriteHeader(http.StatusOK)
	return nil
}

Written by: Chia1104 CC BY-NC-SA 4.0

Chia1104
©
Chia1104