Implementing Web3 Wallet Authentication in Express.js
A comprehensive guide to implementing secure wallet-based authentication using Ethereum signatures in Express.js applications, with real-world use cases and security considerations - 1 Sept 2025
4 min read

Implementing Web3 Wallet Authentication in Express.js
Authentication has always been the gateway to apps β from logging into Facebook with an email/password to OAuth with Google. In Web3 apps (dApps), we can skip passwords entirely.
Instead, users authenticate by signing a message with their crypto wallet. This approach, standardized by Sign-In with Ethereum (SIWE, EIP-4361), is passwordless, secure, and native to Web3.
Why Wallet Authentication?
Real-world scenarios where wallet login shines:
- DeFi dashboards β users sign in to track their assets.
- NFT marketplaces β no signup needed, just connect and trade.
- Crowdfunding dApps β backers pledge directly from their wallet.
Benefits:
- π No password fatigue β wallet is the identity.
- π Security built-in β ownership proven by private key.
- β‘ Frictionless onboarding β no signup forms.
- π Web3-native β interoperable across dApps.
How the Flow Works (SIWE)
- Server generates a nonce (short-lived, one-time).
- User signs a SIWE message containing:
- Domain, URI, chainId
- Address
- Nonce
- Statement (human-readable)
- Client sends
{ message, signature }
. - Server verifies:
- Domain/URI/chain binding
- Nonce matches and not expired
- Signature recovers the claimed address
- Nonce cleared β issue JWT/session.
Database Schema (Prisma Example)
model User {
id String @id @default(uuid())
wallet String @unique
nonce String?
nonceExpiresAt DateTime?
}
Express.js Implementation
Request Nonce
import { randomBytes } from "crypto";
router.post("/auth/request-nonce", async (req, res) => {
const { address } = req.body;
const nonce = "0x" + randomBytes(16).toString("hex");
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes
await db.user.upsert({
where: { wallet: address.toLowerCase() },
update: { nonce, nonceExpiresAt: expiresAt },
create: { wallet: address.toLowerCase(), nonce, nonceExpiresAt: expiresAt },
});
res.json({ nonce, expiresAt: expiresAt.toISOString() });
});
Verify Signature
import { SiweMessage } from "siwe";
import { ethers } from "ethers";
import jwt from "jsonwebtoken";
router.post("/auth/verify", async (req, res) => {
const { message, signature } = req.body;
const siwe = new SiweMessage(message);
const fields = await siwe.validate(signature);
// Validate binding
if (fields.domain !== process.env.APP_DOMAIN) return res.status(401).json({ error: "Invalid domain" });
if (fields.uri !== process.env.APP_ORIGIN) return res.status(401).json({ error: "Invalid URI" });
if (Number(fields.chainId) !== Number(process.env.CHAIN_ID)) return res.status(401).json({ error: "Invalid chainId" });
// Validate nonce
const user = await db.user.findUnique({ where: { wallet: fields.address.toLowerCase() } });
if (!user?.nonce || user.nonce !== fields.nonce) return res.status(401).json({ error: "Nonce mismatch" });
if (!user.nonceExpiresAt || user.nonceExpiresAt < new Date()) return res.status(401).json({ error: "Nonce expired" });
// Verify signature (EOA path)
const recovered = ethers.verifyMessage(siwe.toMessage(), signature);
if (recovered.toLowerCase() !== fields.address.toLowerCase()) return res.status(401).json({ error: "Signature failed" });
// Clear nonce
await db.user.update({
where: { wallet: fields.address.toLowerCase() },
data: { nonce: null, nonceExpiresAt: null },
});
// Issue JWT
const token = jwt.sign({ sub: fields.address.toLowerCase() }, process.env.JWT_SECRET, { expiresIn: "1h" });
res.json({ user: { wallet: fields.address }, token });
});
Frontend Integration (MetaMask + ethers.js)
import { SiweMessage } from "siwe";
import { ethers } from "ethers";
async function signInWithEthereum() {
if (!window.ethereum) throw new Error("Wallet not found");
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const address = (await signer.getAddress()).toLowerCase();
const network = await provider.getNetwork();
// 1. Request nonce
const { nonce } = await fetch("/auth/request-nonce", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address }),
}).then(res => res.json());
// 2. Build SIWE message
const siwe = new SiweMessage({
domain: window.location.host,
address,
statement: "Sign in to Example dApp.",
uri: window.location.origin,
version: "1",
chainId: Number(network.chainId),
nonce,
issuedAt: new Date().toISOString(),
});
const message = siwe.prepareMessage();
// 3. Sign
const signature = await signer.signMessage(message);
// 4. Verify with server
const result = await fetch("/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, signature }),
}).then(res => res.json());
return result; // { user, token }
}
Security Checklist β
- Use SIWE (EIP-4361), not static strings.
- Validate domain, URI, chainId.
- Use short-lived, single-use nonces (expire after 5β10 minutes).
- Clear nonce after successful login.
- Rate limit verification endpoint.
- Handle contract wallets (EIP-1271) if targeting Safe/AA.
- Enforce HTTPS, secure cookies if using sessions.
- Provide a human-readable
statement
β never hide actions.
When to Use Wallet Login?
- β dApps (NFTs, DAOs, DeFi)
- β Web3 SaaS tools needing passwordless onboarding
- β Cross-dApp authentication with one wallet
- β Traditional SaaS apps that need password recovery
Takeaway
Web3 wallet authentication is passwordless, cryptographically secure, and user-friendly.
By implementing SIWE (EIP-4361) with nonces and proper validation, you can provide:
- Safer onboarding
- Seamless login
- A Web3-native identity layer
β‘ Next time youβre building a crowdfunding dApp, a DeFi dashboard, or an NFT marketplace, skip passwords. A wallet signature is all your users need.