Tous les articles

FullStack Web3 Passwordless Authentication Flow: No Abstraction!

Web3 is a combination of web1, web2 and Blockchain. By leveraging the Blockchain security, we can authenticate a user in a decentralized and most secure way without needing to request for password and

Etienne Maway

Etienne Maway

19 avril 2025 · 6 min de lecture

Publié à l’origine sur Medium. Vous le lisez ici, vous pouvez aussi consulter l’original.
FullStack Web3 Passwordless Authentication Flow: No Abstraction!

Web3 is a combination of web1, web2 and Blockchain. By leveraging the Blockchain security, we can authenticate a user in a decentralized and most secure way without needing to request for password and can still issue a JWT Token to ensure only authorized user can access the protected routes. (Yep, you heard it right!)

I prefered using html, css, javascript in the frontend, express in the backend and MongoDB as Database for easy understanding of the flow that’s replicated in any other abstraction package or framework out there. Basic knowledge of these primary Tech stacks and installation of MetaMask alone is prerequisite to continue with this article.

Project Setup: Getting Your Hands Dirty

Before we dive into signing messages and issuing tokens, let’s lay the foundation. We’ll set up a simple full-stack project using:

  • HTML/CSS/JavaScript for the frontend
  • Express for the backend server
  • MongoDB for storing user records
  • MetaMask for wallet-based authentication

1. Create Your Project Structure

Let’s start by creating a folder structure:

bash
mkdir web3-auth
cd web3-auth

2. Set Up the Backend (Express + MongoDB)

Navigate to the backend folder and initialize your Node project:

bash
npm init -y

✅ Install required packages:

bash
npm install express mongoose cors jsonwebtoken dotenv ethers
npm install nodemon --save-dev
  • express: for creating the server
  • mongoose: to interact with MongoDB
  • cors: to allow frontend to communicate with backend
  • jsonwebtoken: for JWT generation
  • dotenv: for environment variables
  • nodemon: for auto-reloading the server during development
  • ethers: Used to verify the wallet signature.
  • dotenv: Loads environment variables like your MongoDB URI and JWT secret.

Create your basic server file:

bash
touch server.js

Add this to server.js:

javascript
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const bodyParser = require("body-parser");
const { ethers } = require("ethers");
const jwt = require("jsonwebtoken");
const mongoose = require("mongoose");

const app = express();
app.use(cors());
app.use(bodyParser.json());

const { MONGO_URI, JWT_SECRET, PORT = 3000 } = process.env;

// --- Connect to MongoDB ---
mongoose
  .connect(MONGO_URI, { useNewUrlParser: true, useUnifiedTopology: true })
  .then(() => console.log("MongoDB connected"))
  .catch((err) => console.error(err));

// --- User Schema ---
const userSchema = new mongoose.Schema({
  walletAddress: { type: String, unique: true, required: true },
  username: { type: String },
  email: { type: String },
  imgUrl: { type: String },
});
const User = mongoose.model("User", userSchema);

// --- Auth Middleware ---
function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader) return res.status(401).json({ error: "JWT token required" });

  const token = authHeader.split(" ")[1];
  try {
    const decoded = jwt.verify(token, JWT_SECRET);

    req.user = decoded;
    next();
  } catch (err) {
    return res.status(403).json({ error: "Invalid or expired token" });
  }
}

// --- In-memory Nonce Store (Replace with Redis in prod) ---
const nonces = {};

// --- Routes ---

// Step 1: Request nonce
app.post("/nonce", async (req, res) => {
  const { walletAddress } = req.body;
  if (!walletAddress)
    return res.status(400).json({ error: "Missing walletAddress" });

  const nonce = `Web3 login at ${new Date().toISOString()} for ${walletAddress}`;
  nonces[walletAddress.toLowerCase()] = nonce;

  // Create user if not exists
  await User.findOneAndUpdate(
    { walletAddress: walletAddress.toLowerCase() },
    {},
    { upsert: true, new: true, setDefaultsOnInsert: true },
  );

  res.json({ nonce });
});

// Step 2: Verify signature and issue JWT
app.post("/verify", async (req, res) => {
  const { walletAddress, signature } = req.body;
  const nonce = nonces[walletAddress.toLowerCase()];
  if (!nonce)
    return res.status(400).json({ error: "No nonce for walletAddress" });

  try {
    const signer = ethers.verifyMessage(nonce, signature);
    if (signer.toLowerCase() !== walletAddress.toLowerCase()) {
      return res.status(401).json({ error: "Signature mismatch" });
    }

    // optionally delete the nonce
    delete nonces[walletAddress.toLowerCase()];

    const token = jwt.sign({ walletAddress }, JWT_SECRET, { expiresIn: "1h" });
    res.json({ token });
  } catch (err) {
    res.status(500).json({ error: "Verification failed" });
  }
});

// GET /me - protected
app.get("/me", authenticate, async (req, res) => {
  const user = await User.findOne({
    walletAddress: req.user.walletAddress.toLowerCase(),
  });
  if (!user) return res.status(404).json({ error: "User not found" });
  res.json(user);
});

// PUT /update-me - protected
app.put("/update-me", authenticate, async (req, res) => {
  const { username, email, imgUrl } = req.body;

  const user = await User.findOneAndUpdate(
    { walletAddress: req.user.walletAddress.toLowerCase() },
    { username, email, imgUrl },
    { new: true },
  );

  if (!user) return res.status(404).json({ error: "User not found" });
  res.json(user);
});

// Start server
app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

The backend of this Web3 authentication system handles three major tasks:

  1. 🔐 Generating a nonce for the user to sign with their wallet
  2. ✅ Verifying the signed message
  3. 🪪 Issuing a JWT token for authenticated access

Let’s walk through the backend code together:

code
mongoose.connect(MONGO_URI, {...})

Your app connects to a local or cloud MongoDB database where user information is stored.

javascript
const userSchema = new mongoose.Schema({
  walletAddress: { type: String, unique: true, required: true },
  username, email, imgUrl
});

Each user is stored in MongoDB using their wallet address as a unique identifier.

javascript
function authenticate(req, res, next) { ... }

This function checks for a valid JWT token in the request header before allowing access to protected endpoints like /me and /update-me.

🔑 Nonce Flow — Login Without Password

This is where the Web3 magic happens!

🔹 Step 1: Request Nonce

code
POST /nonce
  • Frontend sends the wallet address.
  • Backend creates a nonce (a unique message) that the user must sign.
  • If the user doesn’t already exist in MongoDB, they are created.

Why?
This message will later be signed by the user’s wallet to prove ownership.

🔹 Step 2: Verify Signature

code
POST /verify
  • The frontend sends back the wallet address and the signed message.
  • The backend uses ethers.verifyMessage() to recover the signer address from the signature.
  • If it matches the provided wallet address → ✅ success!
  • A JWT token is then issued and sent back to the user. → User is logged in!

This is your authentication — no username or password required.

🧑‍💻 Protected Routes

Once the user is logged in and holds a valid JWT token, they can now access the protected endpoints

code
🟢 GET /me

Returns the user’s profile from MongoDB.

code
🟡 PUT /update-me

Allows the user to update their username, email, or imgUrl.

These routes require the Authorization header:
Authorization: Bearer <your-jwt-token>
javascript
app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

Starts the backend server on http://localhost:3000 by default.

3. Set Up the Frontend (HTML, CSS, JavaScript)

Now, create your frontend by creating these files and respectively paste the codes as follow:

bash
touch index.html script.js style.css

🧩 index.html

html
<!DOCTYPE html>
<html>
  <head>
    <title>Web3 Auth</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <h2>Web3 Login</h2>
    <button id="connectBtn">Connect Wallet</button>
    <button id="loginBtn" disabled>Login</button>
    <button id="disconnectBtn" disabled>Disconnect</button>

    <p id="status">Not connected</p>

    <h3>Get Authenticated User</h3>
    <button id="displayUser">Get Me</button>

    <h3>Update Profile</h3>
    <form id="updateForm">
      <input
        type="text"
        id="username"
        placeholder="Username"
        required /><br /><br />
      <input type="email" id="email" placeholder="Email" required /><br /><br />
      <input
        type="text"
        id="imgUrl"
        placeholder="Image URL"
        required /><br /><br />
      <button type="submit">Update Profile</button>
    </form>

    <div style="font-weight: bold" id="userInfo"></div>

    <script src="script.js"></script>
  </body>
</html>

🎯 script.js

javascript
let walletAddress = null;
let accessToken = null;

// Load saved user state from localStorage
window.onload = () => {
  const savedUser = localStorage.getItem("user");
  if (savedUser) {
    const user = JSON.parse(savedUser);
    walletAddress = user.walletAddress;
    accessToken = user.accessToken;

    if (walletAddress && accessToken) {
      document.getElementById(
        "status",
      ).textContent = `Connected: ${walletAddress}`;
      document.getElementById("connectBtn").disabled = true;
      document.getElementById("loginBtn").disabled = true;
      document.getElementById("disconnectBtn").disabled = false;
    }
  }
};

document.getElementById("connectBtn").onclick = async () => {
  if (typeof window.ethereum === "undefined") {
    alert("Please install MetaMask!");
    return;
  }

  const accounts = await window.ethereum.request({
    method: "eth_requestAccounts",
  });

  walletAddress = accounts[0];
  document.getElementById("status").textContent = `Connected: ${walletAddress}`;
  document.getElementById("loginBtn").disabled = false;
};

document.getElementById("loginBtn").onclick = async () => {
  const res1 = await fetch("http://localhost:3001/nonce", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ walletAddress }),
  });

  const { nonce } = await res1.json();

  const signature = await window.ethereum.request({
    method: "personal_sign",
    params: [nonce, walletAddress],
  });

  const res2 = await fetch("http://localhost:3001/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ walletAddress, signature }),
  });

  const data = await res2.json();

  if (data.token) {
    accessToken = data.token;

    localStorage.setItem(
      "user",
      JSON.stringify({ walletAddress, accessToken }),
    );

    document.getElementById(
      "status",
    ).textContent = `Successfully Authenticated!`;
    document.getElementById("connectBtn").disabled = true;
    document.getElementById("loginBtn").disabled = true;
    document.getElementById("disconnectBtn").disabled = false;
  } else {
    document.getElementById("status").textContent = "Authentication failed";
  }
};

document.getElementById("disconnectBtn").onclick = () => {
  walletAddress = null;
  accessToken = null;
  localStorage.removeItem("user");

  document.getElementById("status").textContent = "Not connected";
  document.getElementById("loginBtn").disabled = true;
  document.getElementById("disconnectBtn").disabled = true;
  document.getElementById("connectBtn").disabled = false;

  alert(
    "To fully disconnect, please open your MetaMask wallet, click on the 3 dots next to the connected site, and disconnect manually.",
  );
};

document.getElementById("displayUser").onclick = async () => {
  if (!accessToken) {
    alert("Valid JWT token required");
    return;
  }

  const response = await fetch("http://localhost:3001/me", {
    method: "GET",
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  });

  const user = await response.json();

  const messageDiv = document.createElement("div");
  messageDiv.innerHTML = `User with walletAddress: ${user.walletAddress} successfully retrieved and logged in the browser console.`;
  document.body.appendChild(messageDiv);

  console.log("User: ", user);
};

document.getElementById("updateForm").onsubmit = async (e) => {
  e.preventDefault();

  const username = document.getElementById("username").value;
  const email = document.getElementById("email").value;
  const imgUrl = document.getElementById("imgUrl").value;

  if (!accessToken) {
    alert("Valid JWT token required");
    return;
  }

  const res = await fetch("http://localhost:3001/update-me", {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },
    body: JSON.stringify({ username, email, imgUrl }),
  });

  const updatedUser = await res.json();

  document.getElementById(
    "userInfo",
  ).textContent = `${updatedUser.username.toUpperCase()} updated, check the browser console`;

  console.log("Updated User:", updatedUser);
};

🎨 style.css

css
body {
  font-family: sans-serif;
  text-align: center;
  margin-top: 50px;
}
button {
  padding: 10px 20px;
  margin: 10px;
  font-size: 16px;
}

form {
  margin-top: 30px;
  display: inline-block;
  text-align: left;
}

form input,
form button {
  width: 100%;
  padding: 8px;
  font-size: 14px;
}

form button {
  margin-top: 10px;
  background-color: #4caf50;
  color: white;
  border: none;
  cursor: pointer;
}

form button:hover {
  background-color: #45a049;
  border-radius: 10px;
}

This is a simple UI that shows connect, login, and profile update options.

Step-by-step:

1. Connect MetaMask

Button: Connect Wallet

  • Requests MetaMask to connect user wallet.
  • Stores the walletAddress.
  • Enables the “Login” button.

2. Login

Button: Login

  • Sends the wallet address to /nonce to get a unique message.
  • Asks MetaMask to sign this message.
  • Sends the signature to /verify.
  • If successful:
  • Stores the JWT token and wallet address in localStorage.
  • Shows a success message.
  • Enables the “Disconnect” button.

✅ This is the authentication handshake.

3. Disconnect

Button: Disconnect

  • Disables login and disconnect buttons.
  • Alerts user to manually disconnect in MetaMask too.

4. Get Authenticated User

Button: Get Me

  • Uses the saved JWT token to request /me.
  • If token is valid, retrieves user details.
  • Shows them in console and adds a message to the DOM.

5. Update Profile

Form: Update Profile

  • Sends a PUT request to /update-me with username, email, and image URL.
  • Requires JWT token in the header.
  • On success, updates UI and logs user info.

🛡️ Security Notes

  • Nonces prevent replay attacks.
  • JWT tokens expire after 1 hour.
  • This demo uses in-memory nonce storage (nonces = {}), but Redis should be used in production.
  • Always validate wallet address and sanitize inputs server-side.

🧪 Summary of Flow

markdown
| Step             | Backend Endpoint     | Frontend Role                                       |
|------------------|----------------------|-----------------------------------------------------|
| Connect Wallet   | —                    | Get wallet from MetaMask                            |
| Request Nonce    | POST `/nonce`        | Ask server for message to sign                      |
| Sign Nonce       | —                    | Sign nonce with wallet using MetaMask               |
| Verify Signature | POST `/verify`       | Send signature for JWT issuance                     |
| Get User         | GET `/me`            | Use JWT to fetch user details                       |
| Update User      | PUT `/update-me`     | Update user info with JWT token                     |

Conclusion

In this demo, we’ve successfully implemented a secure Web3 authentication flow without relying on traditional passwords. By leveraging MetaMask and cryptographic signatures, users can safely verify their identity using only their wallet address. We built a full-stack app with a frontend that connects to MetaMask, signs a challenge (nonce), and receives a JWT token from the backend for authentication.

Once authenticated, users can:

  • Access protected routes like /me to retrieve their own data.
  • Update their profile via the /me route using a PUT request with their username, email, and imgUrl.

This flow demonstrates that passwordless authentication using Web3 tools is not only possible but also secure and user-friendly. With MongoDB for data persistence and Express for API handling, this approach is a solid foundation for modern decentralized or hybrid applications.