Back to Blog
·16 min

Web3 Authentication Guide: Wallet Connect, Sign-In with Ethereum, and Session Management

Authentication in Web3 is fundamentally different from traditional web authentication. Instead of passwords and email verification, users prove ownership of a cryptographic key by signing messages with their wallet. This shift eliminates password-related security risks but introduces new challenges around session management, wallet compatibility, and user experience.

How Web3 Authentication Works

Web3 authentication follows the Sign-In with Ethereum (SIWE) standard (EIP-4361):

1. The user connects their wallet (MetaMask, WalletConnect, etc.)

2. The dApp generates a unique message containing a nonce, domain, and timestamp

3. The user signs the message with their private key

4. The signature is verified on the backend

5. A session is created (JWT or cookie-based)

Setting Up Web3 Auth

git clone https://github.com/breafio/web3-auth-kit.git my-dapp
cd my-dapp
pnpm install
cp .env.example .env.local

Configuration

# .env.local
NEXT_PUBLIC_WALLET_CONNECT_ID=your-walletconnect-project-id
NEXT_PUBLIC_ALCHEMY_API_KEY=your-alchemy-key
NEXT_PUBLIC_CHAIN_ID=1
NEXT_PUBLIC_DOMAIN=localhost:3000
SESSION_SECRET=your-secret-key-at-least-32-chars-long

Message Generation (EIP-4361)

The SIWE message must follow the standard format:

// lib/auth/siwe.ts
import { generateNonce } from 'siwe'
import { SiweMessage } from 'siwe'

export function createSiweMessage(
  address: string,
  domain: string,
  nonce: string,
  chainId: number
): string {
  const message = new SiweMessage({
    domain,
    address,
    statement: 'Sign in to BreafIO with your Ethereum wallet.',
    uri: `https://${domain}`,
    version: '1',
    chainId,
    nonce,
    issuedAt: new Date().toISOString(),
  })

  return message.prepareMessage()
}

Wallet Connection with RainbowKit

RainbowKit provides a beautiful, customizable wallet connection UI:

// app/providers.tsx
'use client'

import { WagmiProvider } from 'wagmi'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RainbowKitProvider, darkTheme, connectorsForWallets } from '@rainbow-me/rainbowkit'
import { metaMaskWallet, walletConnectWallet, coinbaseWallet, rainbowWallet } from '@rainbow-me/rainbowkit/wallets'
import { createConfig, http } from 'wagmi'
import { mainnet, polygon, arbitrum, optimism, base } from 'wagmi/chains'
import '@rainbow-me/rainbowkit/styles.css'

const projectId = process.env.NEXT_PUBLIC_WALLET_CONNECT_ID!

const connectors = connectorsForWallets(
  [
    {
      groupName: 'Popular',
      wallets: [metaMaskWallet, rainbowWallet, coinbaseWallet, walletConnectWallet],
    },
  ],
  { projectId }
)

const config = createConfig({
  connectors,
  chains: [mainnet, polygon, arbitrum, optimism, base],
  transports: {
    [mainnet.id]: http(),
    [polygon.id]: http(),
    [arbitrum.id]: http(),
    [optimism.id]: http(),
    [base.id]: http(),
  },
})

const queryClient = new QueryClient()

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider theme={darkTheme()} coolMode>
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  )
}

Backend Verification

The authentication API verifies signatures and creates sessions:

// app/api/auth/verify/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { SiweMessage } from 'siwe'
import { createSession } from '@/lib/auth/session'

export async function POST(request: NextRequest) {
  try {
    const { message, signature } = await request.json()

    const siweMessage = new SiweMessage(message)
    const fields = await siweMessage.verify({ signature })

    if (fields.error) {
      return NextResponse.json(
        { error: 'Invalid signature' },
        { status: 401 }
      )
    }

    // Create a session for the authenticated user
    const session = await createSession({
      address: fields.data.address,
      chainId: fields.data.chainId,
      nonce: fields.data.nonce,
    })

    return NextResponse.json({ success: true, session })
  } catch (error) {
    return NextResponse.json(
      { error: 'Authentication failed' },
      { status: 500 }
    )
  }
}

Session Management

Sessions can be managed with JWTs or server-side cookies:

// lib/auth/session.ts
import { cookies } from 'next/headers'
import { jwtVerify, SignJWT } from 'jose'

const secret = new TextEncoder().encode(process.env.SESSION_SECRET!)

interface SessionData {
  address: string
  chainId: number
  nonce: string
}

export async function createSession(data: SessionData): Promise<string> {
  const token = await new SignJWT({ ...data })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(secret)

  cookies().set('session', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 7 * 24 * 60 * 60, // 7 days
  })

  return token
}

export async function getSession(): Promise<SessionData | null> {
  const cookieStore = cookies()
  const token = cookieStore.get('session')?.value

  if (!token) return null

  try {
    const { payload } = await jwtVerify(token, secret)
    return payload as unknown as SessionData
  } catch {
    return null
  }
}

Authentication Hook

A React hook that wraps the authentication flow:

// lib/auth/useAuth.ts
'use client'

import { useState, useCallback } from 'react'
import { useAccount, useSignMessage, useDisconnect } from 'wagmi'

export function useAuth() {
  const { address, isConnected } = useAccount()
  const { signMessageAsync } = useSignMessage()
  const { disconnect } = useDisconnect()
  const [isAuthenticated, setIsAuthenticated] = useState(false)
  const [isLoading, setIsLoading] = useState(false)

  const login = useCallback(async () => {
    if (!address) return
    setIsLoading(true)

    try {
      // Get nonce from server
      const nonceRes = await fetch('/api/auth/nonce')
      const { nonce } = await nonceRes.json()

      // Create SIWE message
      const messageRes = await fetch('/api/auth/message', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ address, nonce }),
      })
      const { message } = await messageRes.json()

      // Sign the message
      const signature = await signMessageAsync({ message })

      // Verify on server
      const verifyRes = await fetch('/api/auth/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message, signature }),
      })

      if (verifyRes.ok) {
        setIsAuthenticated(true)
      }
    } catch (error) {
      console.error('Authentication failed:', error)
    } finally {
      setIsLoading(false)
    }
  }, [address, signMessageAsync])

  const logout = useCallback(() => {
    fetch('/api/auth/logout', { method: 'POST' })
    disconnect()
    setIsAuthenticated(false)
  }, [disconnect])

  return {
    address,
    isConnected,
    isAuthenticated,
    isLoading,
    login,
    logout,
  }
}

The Bottom Line

Web3 authentication eliminates password risks but requires careful implementation of the SIWE standard, session management, and wallet compatibility. The Web3 Auth Kit provides a complete, production-ready authentication system that works with all major wallets and includes backend verification, session management, and a clean React hook API.

Ready to Build?

Get started with our production-ready starter kits and ship your project faster.

Browse Starter Kits