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.
The Case for Wallet Authentication
Real-world places where wallet login fits:
- DeFi dashboards. Users sign in to track their assets.
- NFT marketplaces. No signup needed: connect and trade.
- Crowdfunding dApps. Backers pledge directly from their wallet.
The wallet is the identity, so there’s no password to remember. Ownership is proven by the private key, onboarding needs no signup form, and the same wallet is 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.
Implement SIWE (EIP-4361) with nonces and proper validation and you get safer onboarding, a login flow with no password step, and 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.
