Introduction
Web3 authentication replaces traditional username/password with wallet-based authentication. Users prove ownership of an address by signing messages with their private key. This guide covers implementation best practices and security considerations.
Core Concepts
Wallet: Software or hardware that manages private keys and signs transactions.
Private Key: Secret key used to sign transactions and prove ownership.
Public Key: Derived from private key, used to verify signatures.
Message Signing: Proving ownership without sending transactions.
EIP-191: Standard for signing messages in Ethereum.
EIP-712: Standard for structured data signing.
Session Token: JWT or similar token issued after authentication.
Nonce: Random value preventing replay attacks.
Authentication Flow
Web3 Authentication Flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 1. User Connects Wallet โ
โ โโ MetaMask/WalletConnect/etc โ
โโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโผโโโโโโโโโโโโโโโโโ
โ 2. Request Message โ
โ โโ Server generates โ
โ nonce + message โ
โโโโโโโโโโฌโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโผโโโโโโโโโโโโโโโโโ
โ 3. User Signs Message โ
โ โโ Wallet signs with โ
โ private key โ
โโโโโโโโโโฌโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโผโโโโโโโโโโโโโโโโโ
โ 4. Verify Signature โ
โ โโ Server verifies โ
โ signature โ
โโโโโโโโโโฌโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโผโโโโโโโโโโโโโโโโโ
โ 5. Issue Session Token โ
โ โโ JWT or similar โ
โโโโโโโโโโโโโโโโโโโโโโโโโโ
Implementation
1. Request Message for Signing
// Backend: Generate message to sign
async function generateAuthMessage(address) {
const nonce = Math.floor(Math.random() * 1000000);
const timestamp = new Date().toISOString();
const message = `
Welcome to My DApp!
Sign this message to authenticate.
Address: ${address}
Nonce: ${nonce}
Timestamp: ${timestamp}
`.trim();
// Store nonce in database for verification
await saveNonce(address, nonce, timestamp);
return message;
}
// Frontend: Request message
async function requestAuthMessage(address) {
const response = await fetch('/api/auth/message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address })
});
const { message } = await response.json();
return message;
}
2. Sign Message with Wallet
// Frontend: Sign message with MetaMask
async function signMessage(address, message) {
try {
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [message, address]
});
return signature;
} catch (error) {
console.error('User denied signing:', error);
throw error;
}
}
// Alternative: Using ethers.js
import { ethers } from 'ethers';
async function signMessageWithEthers(message) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const signature = await signer.signMessage(message);
return signature;
}
3. Verify Signature
// Backend: Verify signature
const ethers = require('ethers');
async function verifySignature(address, message, signature) {
try {
// Recover address from signature
const recoveredAddress = ethers.utils.verifyMessage(message, signature);
// Check if recovered address matches
if (recoveredAddress.toLowerCase() !== address.toLowerCase()) {
throw new Error('Signature verification failed');
}
// Verify nonce hasn't been used
const nonce = extractNonce(message);
const isValidNonce = await validateNonce(address, nonce);
if (!isValidNonce) {
throw new Error('Invalid or expired nonce');
}
return true;
} catch (error) {
console.error('Verification failed:', error);
return false;
}
}
function extractNonce(message) {
const match = message.match(/Nonce: (\d+)/);
return match ? match[1] : null;
}
4. Issue Session Token
// Backend: Create JWT after verification
const jwt = require('jsonwebtoken');
async function createSessionToken(address) {
const token = jwt.sign(
{
address: address,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (24 * 60 * 60) // 24 hours
},
process.env.JWT_SECRET
);
return token;
}
// Frontend: Store token
async function authenticate(address, message) {
const signature = await signMessage(address, message);
const response = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address,
message,
signature
})
});
const { token } = await response.json();
localStorage.setItem('authToken', token);
return token;
}
Advanced Patterns
1. EIP-712 Structured Data Signing
// More secure: Sign structured data
const domain = {
name: 'My DApp',
version: '1',
chainId: 1,
verifyingContract: '0x...'
};
const types = {
Authentication: [
{ name: 'address', type: 'address' },
{ name: 'nonce', type: 'uint256' },
{ name: 'timestamp', type: 'uint256' }
]
};
const value = {
address: userAddress,
nonce: 12345,
timestamp: Math.floor(Date.now() / 1000)
};
// Sign with ethers.js
const signature = await signer._signTypedData(domain, types, value);
2. Multi-Signature Authentication
// Require multiple signatures
async function multiSigAuth(addresses, message) {
const signatures = [];
for (const address of addresses) {
const sig = await signMessage(address, message);
signatures.push(sig);
}
// Verify all signatures
for (let i = 0; i < addresses.length; i++) {
const recovered = ethers.utils.verifyMessage(
message,
signatures[i]
);
if (recovered.toLowerCase() !== addresses[i].toLowerCase()) {
throw new Error(`Signature ${i} invalid`);
}
}
return signatures;
}
3. Session Management
// Middleware: Verify token
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
}
// Protected route
app.get('/api/user/profile', authMiddleware, (req, res) => {
res.json({ address: req.user.address });
});
Security Best Practices
1. Prevent Replay Attacks
// Use nonce + timestamp
const message = `
Sign to authenticate
Nonce: ${nonce}
Timestamp: ${timestamp}
`;
// Verify nonce is fresh
const messageTime = new Date(timestamp);
const now = new Date();
const diffMinutes = (now - messageTime) / (1000 * 60);
if (diffMinutes > 5) {
throw new Error('Message expired');
}
2. Validate Message Format
function validateMessage(message, address) {
// Check message contains expected content
if (!message.includes(address)) {
throw new Error('Address mismatch');
}
if (!message.includes('Nonce:')) {
throw new Error('Missing nonce');
}
if (!message.includes('Timestamp:')) {
throw new Error('Missing timestamp');
}
return true;
}
3. Rate Limiting
// Limit authentication attempts
const rateLimit = require('express-rate-limit');
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per window
keyGenerator: (req) => req.body.address
});
app.post('/api/auth/verify', authLimiter, verifyHandler);
Common Pitfalls
Pitfall 1: Reusing Messages
Problem: Same message can be replayed.
Solution: Use nonce + timestamp for each message.
Pitfall 2: Not Validating Signature
Problem: Accepting invalid signatures.
Solution: Always verify signature matches address.
Pitfall 3: Storing Private Keys
Problem: Never store private keys.
Solution: Only store public addresses and signatures.
Pros and Cons vs Traditional Auth
| Aspect | Web3 Auth | Traditional |
|---|---|---|
| Security | โ Cryptographic | โ ๏ธ Password-based |
| Privacy | โ Anonymous | โ Requires email |
| Portability | โ Works everywhere | โ Site-specific |
| Recovery | โ ๏ธ Complex | โ Simple |
| UX | โ ๏ธ Wallet required | โ Simple |
Resources
Documentation
Libraries
Tools
Conclusion
Web3 authentication provides cryptographic security and user privacy. Implement proper nonce validation, message verification, and session management for production systems.
Next Steps:
- Implement message generation with nonce
- Add signature verification
- Create session tokens
- Add rate limiting
- Test with multiple wallets
Comments