Web3 Development
Web3 Authentication: Wallet-Based Login for Modern Apps
TL;DR
Wallet-based authentication replaces passwords with cryptographic proof of ownership. Instead of storing credentials, your backend verifies a signed message from the user's wallet. I use wagmi + RainbowKit + SIWE (Sign-In with Ethereum) for every project that needs Web3 auth, and I have built this pattern for multiple clients through my services. This article walks through the full implementation — from RainbowKit setup to SIWE message signing, session management with iron-session, backend verification, multi-chain support, and the UX decisions that make the difference between users completing the login flow and users closing the tab. Every code example is TypeScript, every pattern is production-tested, and every UX consideration comes from watching real users struggle with MetaMask popups for the first time.
Why Wallet Auth
Traditional authentication is a liability. You store passwords (hashed, hopefully), manage reset flows, handle email verification, and pray your database never leaks. Every password your backend stores is a target.
Wallet-based authentication flips the model. The user proves they control a private key by signing a message. Your backend verifies the signature against the claimed address. No passwords stored. No credentials to leak. The cryptographic proof is stateless and unforgeable.
I moved to wallet auth for three reasons:
- No credential storage. My backend never touches a private key or password. The user's wallet handles all cryptographic operations. If my database gets compromised, there are no credentials to steal.
- One identity across apps. A user's wallet address is their universal identity. They can sign into my app, your app, and a DeFi protocol with the same address. No account creation friction.
- Built-in multi-chain identity. The same Ethereum address works on Arbitrum, Optimism, Base, and Polygon. One login covers every chain my app supports.
But wallet auth has real problems. Users who have never signed a message before see a MetaMask popup full of hex data and panic. The signing step feels like a transaction. People think they are spending gas. These are solvable problems, but you have to solve them deliberately. I will cover the UX side later.
RainbowKit Setup
RainbowKit gives you a polished wallet connection modal out of the box. It handles WalletConnect, Coinbase Wallet, MetaMask, and dozens of other wallets. I use it because building a wallet selector from scratch is a waste of time when this library exists.
Here is the setup. Start with the dependencies:
npm install @rainbow-me/rainbowkit wagmi viem @tanstack/react-queryCreate your wagmi config. This defines which chains your app supports and how it connects to them:
// lib/wagmi.ts
import { getDefaultConfig } from "@rainbow-me/rainbowkit";
import { mainnet, arbitrum, optimism, base, polygon } from "wagmi/chains";
export const config = getDefaultConfig({
appName: "My Web3 App",
projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!,
chains: [mainnet, arbitrum, optimism, base, polygon],
ssr: true,
});You need a WalletConnect project ID from cloud.walletconnect.com↗. It is free. Without it, mobile wallets and WalletConnect-based connections will not work.
Now wrap your app with the providers. Order matters here — QueryClientProvider must wrap WagmiProvider, and RainbowKitProvider must be inside WagmiProvider:
// app/providers.tsx
"use client";
import { RainbowKitProvider, darkTheme } from "@rainbow-me/rainbowkit";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "@/lib/wagmi";
import "@rainbow-me/rainbowkit/styles.css";
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider
theme={darkTheme({
accentColor: "#F7931A",
accentColorForeground: "white",
borderRadius: "medium",
})}
>
{children}
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
);
}I always customize the RainbowKit theme to match my brand. The darkTheme function accepts accent colors, border radius, and font stack. Your wallet modal should not look like it belongs to a different app.
Drop the connect button anywhere in your app:
// components/ConnectButton.tsx
"use client";
import { ConnectButton } from "@rainbow-me/rainbowkit";
export function WalletConnect() {
return <ConnectButton showBalance={false} chainStatus="icon" />;
}At this point, users can connect their wallet. But connection is not authentication. Connecting a wallet just means wagmi knows the user's address. It does not prove they own it. That is where SIWE comes in.
Sign-In with Ethereum (SIWE)
SIWE (EIP-4361) is the standard for wallet-based authentication. It defines a human-readable message format that the user signs with their wallet. Your backend verifies the signature and creates a session.
The flow works like this:
- User connects their wallet (RainbowKit handles this).
- Your backend generates a nonce.
- Your frontend constructs a SIWE message with the nonce, domain, address, and chain ID.
- The user signs the message in their wallet.
- Your frontend sends the message and signature to your backend.
- Your backend verifies the signature, checks the nonce, and creates a session.
Install the SIWE package:
npm install siwe iron-sessionFirst, set up the session configuration. I use iron-session because it encrypts the session data in a cookie — no session store needed:
// lib/session.ts
import { SessionOptions } from "iron-session";
export interface SessionData {
address?: string;
chainId?: number;
nonce?: string;
isLoggedIn: boolean;
}
export const sessionOptions: SessionOptions = {
cookieName: "web3_session",
password: process.env.SESSION_SECRET!,
cookieOptions: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "strict" as const,
maxAge: 60 * 60 * 24 * 7, // 7 days
},
};
export const defaultSession: SessionData = {
isLoggedIn: false,
};Now create the API routes. You need three endpoints — nonce generation, verification, and logout:
// app/api/auth/nonce/route.ts
import { NextResponse } from "next/server";
import { generateNonce } from "siwe";
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
import { sessionOptions, SessionData } from "@/lib/session";
export async function GET() {
const session = await getIronSession<SessionData>(
await cookies(),
sessionOptions
);
const nonce = generateNonce();
session.nonce = nonce;
await session.save();
return NextResponse.json({ nonce });
}// app/api/auth/verify/route.ts
import { NextRequest, NextResponse } from "next/server";
import { SiweMessage } from "siwe";
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
import { sessionOptions, SessionData } from "@/lib/session";
export async function POST(request: NextRequest) {
const { message, signature } = await request.json();
const session = await getIronSession<SessionData>(
await cookies(),
sessionOptions
);
const siweMessage = new SiweMessage(message);
const { data: fields } = await siweMessage.verify({
signature,
nonce: session.nonce,
});
if (fields.nonce !== session.nonce) {
return NextResponse.json(
{ code: "INVALID_NONCE", message: "Nonce mismatch" },
{ status: 422 }
);
}
session.address = fields.address;
session.chainId = fields.chainId;
session.isLoggedIn = true;
session.nonce = undefined;
await session.save();
return NextResponse.json({
address: fields.address,
chainId: fields.chainId,
});
}// app/api/auth/logout/route.ts
import { NextResponse } from "next/server";
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
import { sessionOptions, SessionData } from "@/lib/session";
export async function POST() {
const session = await getIronSession<SessionData>(
await cookies(),
sessionOptions
);
session.destroy();
return NextResponse.json({ ok: true });
}The nonce is critical. Without it, an attacker could replay a previously signed message. Generate a fresh nonce for every sign-in attempt, store it in the session, and verify it matches during verification. After successful verification, clear the nonce so it cannot be reused.
Session Management
With the backend routes in place, you need a client-side hook that ties the SIWE flow into the wallet connection lifecycle. This hook handles the full sequence — fetch nonce, construct message, request signature, verify with backend:
// hooks/useSiweAuth.ts
"use client";
import { useAccount, useSignMessage, useChainId } from "wagmi";
import { SiweMessage } from "siwe";
import { useCallback, useEffect, useState } from "react";
interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
address: string | undefined;
}
export function useSiweAuth() {
const { address, isConnected } = useAccount();
const chainId = useChainId();
const { signMessageAsync } = useSignMessage();
const [authState, setAuthState] = useState<AuthState>({
isAuthenticated: false,
isLoading: false,
address: undefined,
});
const signIn = useCallback(async () => {
if (!address || !isConnected) return;
setAuthState((prev) => ({ ...prev, isLoading: true }));
try {
const nonceRes = await fetch("/api/auth/nonce");
const { nonce } = await nonceRes.json();
const message = new SiweMessage({
domain: window.location.host,
address,
statement: "Sign in to My Web3 App",
uri: window.location.origin,
version: "1",
chainId,
nonce,
});
const signature = await signMessageAsync({
message: message.prepareMessage(),
});
const verifyRes = await fetch("/api/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: message.toMessage(),
signature,
}),
});
if (!verifyRes.ok) {
throw new Error("Verification failed");
}
setAuthState({
isAuthenticated: true,
isLoading: false,
address,
});
} catch (error) {
setAuthState({
isAuthenticated: false,
isLoading: false,
address: undefined,
});
}
}, [address, isConnected, chainId, signMessageAsync]);
const signOut = useCallback(async () => {
await fetch("/api/auth/logout", { method: "POST" });
setAuthState({
isAuthenticated: false,
isLoading: false,
address: undefined,
});
}, []);
useEffect(() => {
if (!isConnected) {
setAuthState({
isAuthenticated: false,
isLoading: false,
address: undefined,
});
}
}, [isConnected]);
return { ...authState, signIn, signOut };
}The key detail here is that wallet disconnection should always clear the auth state. If a user disconnects their wallet, your app must treat them as logged out — even if the session cookie is still valid. I have seen apps where disconnecting the wallet leaves the user "authenticated" in a broken state. Check isConnected in an effect and clean up accordingly.
Connecting to Your Backend
Once SIWE verification succeeds, your backend has a session with the user's address. Now you need to use that session to protect your API routes. Here is a middleware pattern I use on every project:
// lib/auth.ts
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
import { sessionOptions, SessionData } from "@/lib/session";
import { NextResponse } from "next/server";
export async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions);
}
export async function requireAuth() {
const session = await getSession();
if (!session.isLoggedIn || !session.address) {
return {
session: null,
error: NextResponse.json(
{ code: "UNAUTHORIZED", message: "Authentication required" },
{ status: 401 }
),
};
}
return { session, error: null };
}Use it in any protected route:
// app/api/user/profile/route.ts
import { NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
const { session, error } = await requireAuth();
if (error) return error;
const user = await prisma.user.findUnique({
where: { walletAddress: session!.address },
});
if (!user) {
return NextResponse.json(
{ code: "NOT_FOUND", message: "User not found" },
{ status: 404 }
);
}
return NextResponse.json({ user });
}One pattern I always implement is auto-creating user records on first sign-in. The first time an address authenticates, create a row in your users table. This avoids a separate "registration" step, which makes no sense in Web3 — connecting a wallet is registration:
// app/api/auth/verify/route.ts (updated)
// After successful SIWE verification:
await prisma.user.upsert({
where: { walletAddress: fields.address },
update: { lastLoginAt: new Date() },
create: {
walletAddress: fields.address,
lastLoginAt: new Date(),
},
});The upsert pattern handles both first-time and returning users in a single query. No conditional logic needed.
Multi-Chain Support
If your app supports multiple chains, you need to handle chain switching gracefully. The user might connect on Ethereum mainnet, then switch to Arbitrum. Their address stays the same, but the chain context changes.
wagmi gives you hooks for this:
// hooks/useChainSync.ts
"use client";
import { useAccount, useChainId } from "wagmi";
import { useSwitchChain } from "wagmi";
import { useEffect } from "react";
const SUPPORTED_CHAIN_IDS = [1, 42161, 10, 8453, 137];
export function useChainSync() {
const { isConnected } = useAccount();
const chainId = useChainId();
const { switchChain } = useSwitchChain();
const isSupported = SUPPORTED_CHAIN_IDS.includes(chainId);
useEffect(() => {
if (isConnected && !isSupported) {
switchChain({ chainId: SUPPORTED_CHAIN_IDS[0] });
}
}, [isConnected, isSupported, switchChain]);
return { chainId, isSupported };
}The SIWE message includes a chainId field. When I verify the signature on the backend, I also check that the chain ID is one my app supports. If a user somehow signs a message on an unsupported chain, the verification rejects it:
// In your verify route, add this check:
const SUPPORTED_CHAINS = [1, 42161, 10, 8453, 137];
if (!SUPPORTED_CHAINS.includes(fields.chainId)) {
return NextResponse.json(
{ code: "UNSUPPORTED_CHAIN", message: "Chain not supported" },
{ status: 400 }
);
}For apps where the on-chain activity matters (DeFi, NFT minting), re-authenticate when the chain changes. For apps where the address is the identity and the chain is irrelevant (social, content), skip re-authentication on chain switch and just update the UI.
UX Considerations
This is where most Web3 auth implementations fail. The cryptography works fine. The UX does not.
The signing popup scares people. When MetaMask asks a user to sign a message, they see something that looks like a transaction confirmation. Users who have been told "never sign anything you do not understand" will close the popup. You need to prepare them before the popup appears:
// components/SignInPrompt.tsx
"use client";
import { useSiweAuth } from "@/hooks/useSiweAuth";
export function SignInPrompt() {
const { signIn, isLoading, isAuthenticated } = useSiweAuth();
if (isAuthenticated) return null;
return (
<div className="rounded-xl border border-white/10 bg-[#111827] p-6">
<h3 className="text-lg font-semibold text-white">
Verify your wallet
</h3>
<p className="mt-2 text-sm text-[#C9D1E0]">
You will be asked to sign a message in your wallet.
This is free and does not trigger a blockchain transaction.
It simply proves you own this address.
</p>
<button
onClick={signIn}
disabled={isLoading}
className="mt-4 rounded-lg bg-[#F7931A] px-6 py-2.5 text-sm
font-medium text-white transition-colors
hover:bg-[#E07B0A] disabled:opacity-50"
>
{isLoading ? "Check your wallet..." : "Sign in with wallet"}
</button>
</div>
);
}Three things this component does right:
- Explains what will happen before it happens. The user reads "this is free" and "does not trigger a blockchain transaction" before seeing the MetaMask popup.
- Uses "Check your wallet..." as the loading state. This directs attention to MetaMask. Without this, users click the button, nothing visible happens on the page, and they click it again — generating a second signing request.
- Keeps the message simple. No technical jargon. No mention of SIWE, nonces, or EIP-4361. Users do not care about the protocol. They care about whether they are going to lose money.
Handle wallet not installed. Not everyone has MetaMask. Check before showing the connect flow:
// hooks/useWalletDetection.ts
"use client";
import { useEffect, useState } from "react";
export function useWalletDetection() {
const [hasWallet, setHasWallet] = useState(true);
useEffect(() => {
setHasWallet(typeof window.ethereum !== "undefined");
}, []);
return { hasWallet };
}If no wallet is detected, show a message directing users to install one. RainbowKit handles this partially — it shows install links for supported wallets — but I always add my own fallback UI with a clear explanation of what a wallet is and why they need one.
Remember the session. Nobody wants to sign a message every time they refresh the page. The iron-session cookie persists across page loads. On mount, check if the session is still valid:
// In your layout or auth provider:
useEffect(() => {
async function checkSession() {
const res = await fetch("/api/auth/session");
if (res.ok) {
const data = await res.json();
if (data.isLoggedIn) {
setAuthState({
isAuthenticated: true,
isLoading: false,
address: data.address,
});
}
}
}
checkSession();
}, []);This avoids the sign-in popup on every page load while still maintaining security through the server-side session.
Security Best Practices
Wallet auth shifts security concerns from credential storage to message verification. Here is what I check on every project:
Validate the domain. The SIWE message includes the domain that requested the signature. On your backend, verify it matches your actual domain:
const expectedDomain = new URL(process.env.NEXT_PUBLIC_APP_URL!).host;
if (fields.domain !== expectedDomain) {
return NextResponse.json(
{ code: "DOMAIN_MISMATCH", message: "Invalid domain" },
{ status: 400 }
);
}Without this check, an attacker could trick a user into signing a SIWE message on a phishing site and replay it against your backend.
Expire nonces aggressively. A nonce should be valid for one use within a short window. I set a 5-minute expiry:
// In your session config, store nonce creation time:
session.nonce = nonce;
session.nonceIssuedAt = Date.now();
await session.save();
// In verification:
const NONCE_EXPIRY = 5 * 60 * 1000; // 5 minutes
if (Date.now() - (session.nonceIssuedAt ?? 0) > NONCE_EXPIRY) {
return NextResponse.json(
{ code: "NONCE_EXPIRED", message: "Nonce expired. Please try again." },
{ status: 422 }
);
}Rate limit the nonce endpoint. Without rate limiting, an attacker can flood your nonce endpoint and fill up your session store. Apply a basic rate limiter — 10 requests per minute per IP is reasonable.
Never trust the client. The address in the SIWE message must match the address that signed it. The siwe library handles this verification, but always double-check: the address in your session should come from the verified SIWE fields, never from a client-sent parameter.
Set secure cookie flags. I covered this in the session config, but it bears repeating: httpOnly: true prevents JavaScript access to the session cookie. sameSite: strict blocks cross-site request forgery. secure: true in production ensures the cookie only travels over HTTPS.
Add CSRF protection for state-changing routes. Even with sameSite: strict, adding a CSRF token to POST requests is defense in depth. iron-session makes this straightforward — store a CSRF token in the session and validate it on every mutation.
Log authentication events. Every sign-in, sign-out, and failed verification attempt should be logged with the wallet address and timestamp. When something goes wrong — and in production, something always goes wrong — these logs are how you reconstruct what happened.
Key Takeaways
- Wallet auth eliminates credential storage. No passwords, no hashing, no breach risk. The user proves ownership through cryptographic signatures.
- SIWE is the standard. Do not roll your own message format. EIP-4361 defines a human-readable, verifiable message structure. Use it.
- RainbowKit + wagmi handle the hard parts. Wallet detection, connection management, chain switching, and UI are solved problems. Use the libraries.
- Nonces prevent replay attacks. Generate a fresh nonce for every sign-in attempt. Expire it after 5 minutes. Never reuse.
- UX wins or loses the user. Explain what signing means before the popup appears. Use clear loading states. Persist sessions so users do not re-sign on every visit.
- Validate everything server-side. Domain, nonce, chain ID, signature — verify all of it on your backend. The client is not trusted.
- Multi-chain is straightforward. The address is the identity. The chain is context. Decide whether chain switches need re-authentication based on your app's requirements.
If you are building an app that needs Web3 authentication — or you want a production implementation of the patterns in this article — check out my services. I have shipped this exact stack for multiple clients, and every deployment has been hardened against the edge cases covered here.
*Written by Uvin Vindula↗ -- Web3 engineer and builder based between Sri Lanka and the UK. I write about smart contracts, decentralized systems, and the tools I use to ship production Web3 applications. Follow my work at @IAMUVIN↗.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.