Implementing a Secure MetaMask Login System

·

In the current landscape of web development, social login systems are predominantly reliant on centralized providers like Google or QQ. For Web3 applications, a decentralized alternative is not just preferable but often necessary. This guide will walk you through building a secure, one-click login system using the MetaMask wallet API.

We will cover both frontend implementation with Vue.js and a serverless backend utilizing Cloudflare Workers and KV storage. The core of this system leverages Ethereum's built-in asymmetric cryptography to verify user identity through digital signatures, replacing traditional passwords or verification codes.

Understanding the Web3 Login Flow

At its heart, login is about proving user identity. Where traditional systems use passwords or verification codes, blockchain technology offers a more secure foundation through cryptographic signatures.

A digital signature allows users to sign arbitrary data with their private key, creating proof that can be verified against their public Ethereum address. MetaMask provides the signing functionality through its API, while our backend handles verification and token issuance.

The complete flow involves:

  1. Frontend requests the user's Ethereum address
  2. Backend generates a unique nonce for signature
  3. User signs the nonce with their private key
  4. Signature is verified backend
  5. JSON Web Token (JWT) is issued upon successful verification

👉 Explore more Web3 authentication strategies

Frontend Implementation with Vue.js

We'll create a simple Vue component that handles the MetaMask integration and signature process.

Basic Component Structure

First, let's set up our Vue component with the necessary data properties:

<template>
  <div>
    <button v-if="metaMaskSupport" @click="login">Login with MetaMask</button>
    <p v-else>MetaMask is not installed</p>
  </div>
</template>

<script>
import axios from "axios";

export default {
  name: "MetaMaskLogin",
  data() {
    return {
      metaMaskSupport: false,
      ethAccount: null,
      signature: null,
      nonce: null,
    };
  },
  mounted() {
    this.checkMetaMaskSupport();
  },
  methods: {
    checkMetaMaskSupport() {
      this.metaMaskSupport = window.ethereum && window.ethereum.isMetaMask;
    },
    async login() {
      // Implementation will be added step by step
    }
  }
};
</script>

Retrieving Ethereum Account Address

The first step in our login process is requesting the user's Ethereum address:

async login() {
  try {
    const accounts = await window.ethereum.request({ 
      method: 'eth_requestAccounts' 
    });
    this.ethAccount = accounts[0];
    console.log("User address:", this.ethAccount);
    
    // Continue to nonce request
    await this.requestNonce();
  } catch (error) {
    console.error("Error retrieving accounts:", error);
  }
}

This code uses the eth_requestAccounts method from the MetaMask API, which prompts the user to connect their wallet if they haven't already done so.

Backend Setup with Cloudflare Workers

For our backend, we'll use Cloudflare Workers for its serverless architecture and global distribution. We'll also utilize Cloudflare KV for key-value storage of nonces.

Initializing the Worker Environment

After setting up Wrangler (Cloudflare's CLI tool), create a new worker project:

wrangler init web3-login-worker

Configure your KV namespace in wrangler.toml:

name = "web3-login-worker"
main = "src/index.js"
compatibility_date = "2023-05-18"

kv_namespaces = [
  { binding = "LOGIN_KV", id = "your-namespace-id", preview_id = "your-preview-id" }
]

[vars]
SECRET_KEY = "your-jwt-secret-key"

Generating and Storing Nonces

Nonces (number used once) are crucial for preventing replay attacks. We'll generate a random nonce for each login attempt and store it in KV with a short expiration time.

addEventListener("fetch", event => {
  event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
  // Handle CORS preflight requests
  if (request.method === "OPTIONS") {
    return handleCors();
  }
  
  // Handle nonce generation requests
  if (request.method === "PUT") {
    return handleNonceRequest(request);
  }
  
  // Handle signature verification requests
  if (request.method === "POST") {
    return handleVerificationRequest(request);
  }
  
  return new Response("Method not allowed", { status: 405 });
}

async function handleNonceRequest(request) {
  try {
    const { from } = await request.json();
    const nonce = Math.floor(Math.random() * 1000000);
    
    // Store nonce with 2-minute expiration
    await LOGIN_KV.put(from, nonce.toString(), { expirationTtl: 120 });
    
    return corsResponse(JSON.stringify({ nonce, address: from }));
  } catch (error) {
    return new Response("Error generating nonce", { status: 500 });
  }
}

Requesting Nonce and Signature (Frontend)

With our backend ready to generate nonces, we need to implement the frontend code to request and use them.

Fetching the Nonce

Add the nonce request method to your Vue component:

async requestNonce() {
  try {
    const response = await axios.put(
      "https://your-worker.workers.dev/", 
      { from: this.ethAccount }
    );
    
    this.nonce = response.data.nonce;
    console.log("Received nonce:", this.nonce);
    
    // Proceed to signature
    await this.signNonce();
  } catch (error) {
    console.error("Error requesting nonce:", error);
  }
}

Creating the Signature

We'll use MetaMask's signTypedData_v4 method, which follows the EIP-712 standard for structured data signing:

async signNonce() {
  const msgParams = {
    domain: {
      chainId: window.ethereum.chainId,
      name: 'Web3 Login',
      version: '1'
    },
    message: {
      contents: 'Login request',
      nonce: this.nonce,
    },
    primaryType: 'Login',
    types: {
      EIP712Domain: [
        { name: 'name', type: 'string' },
        { name: 'version', type: 'string' },
        { name: 'chainId', type: 'uint256' },
      ],
      Login: [
        { name: 'contents', type: 'string' },
        { name: 'nonce', type: 'uint256' },
      ],
    },
  };

  try {
    this.signature = await window.ethereum.request({
      method: 'eth_signTypedData_v4',
      params: [this.ethAccount, JSON.stringify(msgParams)],
    });
    
    console.log("Signature:", this.signature);
    
    // Send signature for verification
    await this.verifySignature();
  } catch (error) {
    console.error("Signing failed:", error);
  }
}

Submitting the Signature for Verification

Once we have the signature, we send it to our backend for verification:

async verifySignature() {
  try {
    const response = await axios.post(
      "https://your-worker.workers.dev/",
      {
        chainId: window.ethereum.chainId,
        from: this.ethAccount,
        signature: this.signature
      }
    );
    
    if (response.data.verify) {
      // Store authentication tokens
      localStorage.setItem("token", response.data.token);
      localStorage.setItem("expire", Date.now() + 3600000);
      localStorage.setItem("userAddress", this.ethAccount);
      
      console.log("Login successful!");
    } else {
      console.log("Login failed. Please try again.");
    }
  } catch (error) {
    console.error("Verification error:", error);
  }
}

Backend Signature Verification and JWT Generation

The final piece is implementing signature verification on the backend and issuing JWTs upon successful authentication.

Verifying the Signature

We use the @metamask/eth-sig-util library to recover the address from the signature:

import { recoverTypedSignature } from '@metamask/eth-sig-util';

async function handleVerificationRequest(request) {
  try {
    const { chainId, from, signature } = await request.json();
    
    // Retrieve stored nonce
    const storedNonce = await LOGIN_KV.get(from);
    
    if (!storedNonce) {
      return corsResponse(JSON.stringify({ verify: false }), 401);
    }
    
    // Reconstruct the signed message
    const msgParams = {
      domain: {
        chainId: chainId,
        name: 'Web3 Login',
        version: '1'
      },
      message: {
        contents: 'Login request',
        nonce: parseInt(storedNonce),
      },
      primaryType: 'Login',
      types: {
        EIP712Domain: [
          { name: 'name', type: 'string' },
          { name: 'version', type: 'string' },
          { name: 'chainId', type: 'uint256' },
        ],
        Login: [
          { name: 'contents', type: 'string' },
          { name: 'nonce', type: 'uint256' },
        ],
      },
    };
    
    // Recover address from signature
    const recoveredAddress = recoverTypedSignature({
      data: msgParams,
      signature: signature,
      version: 'V4',
    });
    
    // Compare addresses
    if (recoveredAddress.toLowerCase() === from.toLowerCase()) {
      // Generate JWT
      const token = await generateJWT(from);
      
      // Clean up used nonce
      await LOGIN_KV.delete(from);
      
      return corsResponse(JSON.stringify({ 
        verify: true, 
        token: token 
      }));
    } else {
      return corsResponse(JSON.stringify({ verify: false }), 401);
    }
  } catch (error) {
    return new Response("Verification error", { status: 500 });
  }
}

Generating JSON Web Tokens

For successful verifications, we issue a JWT that can be used for subsequent authenticated requests:

import { SignJWT } from 'jose';

async function generateJWT(address) {
  const secret = new TextEncoder().encode(env.SECRET_KEY);
  
  const token = await new SignJWT({ address })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('1h')
    .sign(secret);
    
  return token;
}

CORS Handling Utility

Since our frontend and backend are on different domains, we need proper CORS handling:

function corsResponse(body, status = 200) {
  const headers = {
    'Content-Type': 'application/json',
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type',
  };
  
  return new Response(body, { status, headers });
}

function handleCors() {
  return new Response(null, {
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
      'Access-Control-Max-Age': '86400',
    }
  });
}

Security Considerations and Best Practices

When implementing Web3 authentication, several security aspects deserve special attention:

  1. Nonce Management: Ensure nonces are single-use and have short expiration times to prevent replay attacks.
  2. Signature Verification: Always verify signatures on the backend—never trust client-side verification.
  3. JWT Security: Use strong secrets for JWT signing and implement proper token expiration policies.
  4. Frontend Security: Store tokens securely using appropriate client-side storage mechanisms.
  5. Error Handling: Implement comprehensive error handling to avoid leaking sensitive information.

👉 Get advanced security methods for Web3 applications

Frequently Asked Questions

What is the advantage of Web3 login over traditional authentication?
Web3 login eliminates password management for users and reduces phishing risks. Users maintain control of their identity through their cryptocurrency wallet, without relying on third-party authentication providers.

Can users without MetaMask still access my application?
Yes, you should implement a fallback authentication method for users without Web3 wallets. This could be a traditional email/password system or social login options.

How secure is the signature process?
The signature process is highly secure when implemented correctly. The private key never leaves the user's wallet, and each signature is unique to the specific login request thanks to the nonce.

What happens if a user rejects the signature request?
Your application should handle signature rejection gracefully, typically by displaying a message that authentication cannot proceed without the required signature.

Can I use this approach with other Ethereum wallets?
Yes, while this guide focuses on MetaMask, the same principles apply to other Ethereum-compatible wallets that support the EIP-712 signature standard.

How do I handle different Ethereum networks?
Your application should either restrict which networks are supported or implement network-aware functionality. You can check the chainId from MetaMask to determine the current network.

Conclusion

Implementing a MetaMask login system provides a seamless and secure authentication experience for Web3 applications. By leveraging Ethereum's cryptographic capabilities, we can create authentication flows that are both user-friendly and highly secure.

This guide has walked you through the complete implementation from frontend to backend, including signature generation, verification, and token management. Remember to always prioritize security considerations and provide fallback options for users who may not yet be using Web3 wallets.

The decentralized authentication approach demonstrated here represents the future of user identity on the internet—one where users maintain control of their digital identities without relying on centralized intermediaries.