Skip to main content

Web3 Authentication: Wallet Integration Best Practices

Published: December 22, 2025 Updated: May 8, 2026 Larry Qu 5 min read

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:

  1. Implement message generation with nonce
  2. Add signature verification
  3. Create session tokens
  4. Add rate limiting
  5. Test with multiple wallets

Comments

👍 Was this article helpful?