Skip to main content
โšก Calmops

Web3 Authentication: Wallet Integration Best Practices

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