跳转到内容
123xiao | 无名键客

《Web3 中级实战:从零搭建基于钱包登录与链上签名的去中心化身份认证系统》

字数: 0 阅读时长: 1 分钟

Web3 中级实战:从零搭建基于钱包登录与链上签名的去中心化身份认证系统

在传统 Web 应用里,身份认证通常绕不开“账号 + 密码 + Session/JWT”这套组合拳。但到了 Web3 场景,用户未必愿意注册账号,更不想把密码托管给某个中心化服务。更常见的诉求是:直接使用钱包地址作为身份入口,并通过签名证明“这个地址现在由我控制”

这篇文章我会从架构角度,带你搭建一套中等复杂度、可实际运行的去中心化身份认证系统。它不是只讲原理,也不是只贴几段零散代码,而是从背景、协议设计、前后端实现、风险点与最佳实践一路串起来,最终形成一套完整可落地的方案。


背景与问题

为什么“钱包地址 = 身份”还不够?

很多刚接触 Web3 的同学会有个误区:既然用户有钱包地址,那直接把地址当作用户 ID 不就行了?

问题是:地址是公开信息,不是身份证明。

我知道某个地址是 0xabc...,并不代表我控制这个地址。真正能证明控制权的,是:

  • 使用私钥发起交易
  • 或使用私钥对一段指定消息进行签名

而在“登录”场景里,我们通常不需要用户真正上链发交易,因为那样会产生 Gas 成本,也会增加交互负担。于是,**链下签名(off-chain signature)**就成了最适合的钱包登录方式。

这套系统要解决哪些问题?

一个可用的去中心化身份认证系统,至少要回答下面几个问题:

  1. 如何证明用户控制某个钱包地址?
  2. 如何防止签名被重放?
  3. 登录后如何维持会话?
  4. 如何兼容多钱包、多链环境?
  5. 如何避免把“Web2 的问题”原封不动搬进 Web3?

如果这些问题处理不好,系统很容易出现:

  • 签名可被重放
  • 前后端消息格式不一致
  • 地址大小写导致验签失败
  • Session 被盗用
  • 用户切换链后状态混乱
  • 把“签名登录”误做成“链上写入”,白白增加成本

方案目标与边界

先定一下这篇文章的目标边界,避免做成一个无限扩张的大工程。

本文实现目标

我们要做的是一套典型的 钱包登录 + 链下签名 + 服务端验签 + JWT 会话 系统,包含:

  • 前端连接 MetaMask
  • 服务端下发一次性 nonce
  • 用户使用钱包签名登录消息
  • 服务端验证签名并恢复地址
  • 验签成功后签发 JWT
  • 后续接口通过 JWT 识别用户身份

本文不展开的部分

以下内容会提到,但不深入实现:

  • DID 文档解析
  • ENS / Lens / NFT Profile 扩展身份
  • 多签钱包(如 Safe)的 EIP-1271 完整支持
  • 跨链统一身份聚合
  • 零知识证明身份协议

也就是说,本文的重点是:先把“EOA 钱包登录”这条主链路搭牢


整体架构设计

我们先看系统组件,再看交互流程。

系统组件

  • 前端 DApp
    • 发起钱包连接
    • 请求服务端生成 nonce
    • 调用钱包签名
    • 提交签名给服务端完成登录
  • 认证服务 Auth Server
    • 生成并保存 nonce
    • 构造标准登录消息
    • 验证签名
    • 签发 JWT / Session
  • 存储层
    • 保存 nonce、用户资料、会话元数据
  • 区块链 / 钱包
    • 用户签名的根信任来源

架构流转图

flowchart LR
  U[用户] --> W[钱包 MetaMask]
  U --> F[前端 DApp]
  F --> A[认证服务]
  A --> D[(数据库/Redis)]
  W -.签名能力.-> F
  A -.地址恢复/验签.-> A

登录时序图

sequenceDiagram
  participant U as 用户
  participant F as 前端
  participant W as 钱包
  participant A as 认证服务
  participant D as 数据库

  U->>F: 点击“钱包登录”
  F->>W: 请求连接钱包
  W-->>F: 返回 address
  F->>A: GET /auth/nonce?address=0x...
  A->>D: 保存 nonce
  A-->>F: 返回 nonce + message
  F->>W: personal_sign(message)
  W-->>F: signature
  F->>A: POST /auth/verify { address, message, signature }
  A->>A: 恢复签名地址并比对
  A->>D: 标记 nonce 已使用
  A-->>F: 返回 JWT
  F->>A: 携带 JWT 访问业务接口

核心原理

1. 钱包登录本质是“签名认证”

整个过程的本质不是“连接钱包”,而是:

服务端给一个随机挑战,用户用私钥签名,服务端验证签名是否来自该地址。

这和传统认证中的 challenge-response 很像,只不过密码换成了钱包私钥,且私钥始终留在用户钱包里。

2. 为什么一定要 nonce?

如果你让用户签一段固定文本,比如:

Login to MyDApp

那攻击者只要拿到这段签名,就可以无限次拿它冒充用户登录。
这就是重放攻击(Replay Attack)

所以,登录消息里必须包含:

  • 一次性随机 nonce
  • 域名 / 应用标识
  • 时间戳或过期时间
  • 钱包地址
  • 可选的 chainId

一个更稳妥的消息大概长这样:

Welcome to MyDApp

Address: 0x1234...
Nonce: 8d3c7d01b3...
Chain ID: 1
Issued At: 2024-01-01T12:00:00Z
Statement: Sign this message to authenticate.

3. 验签如何恢复地址?

以 EVM 兼容链为例,服务端通常会:

  1. 对消息按 personal_sign 规范加前缀
  2. 使用椭圆曲线恢复公钥
  3. 从公钥推导出地址
  4. 与前端提交的地址比较

如果恢复出的地址一致,就说明签名确实由该地址对应私钥生成。

4. 为什么登录通常不需要上链?

因为上链交易解决的是“状态共识”,而登录解决的是“控制权证明”。

登录时不需要区块链共识,只需要密码学证明,因此:

  • 不需要 Gas
  • 不需要等待确认
  • 体验更接近普通 Web 登录

这也是“链上签名”这个说法在很多团队里容易混淆的地方。更准确地说,登录通常是链下签名,链上地址身份。但从业务口径上,大家常把它统称为“链上签名认证”。


方案对比与取舍分析

在设计认证系统时,常见有三种思路。

方案一:纯钱包签名 + 服务端 JWT

优点:

  • 实现简单
  • 兼容现有 Web 架构
  • 业务接口易接入权限系统

缺点:

  • 认证结果仍依赖中心化服务端签发 token
  • 需要管理 JWT 生命周期

适用:

  • 大多数 DApp 后台
  • NFT 平台、任务平台、社区系统

方案二:纯链上身份合约认证

优点:

  • 更“原教旨”的去中心化
  • 身份规则透明可审计

缺点:

  • 登录成本高
  • 性能差
  • 用户体验不友好

适用:

  • 极少数必须链上留痕的高安全场景

方案三:钱包签名 + DID/VC 扩展

优点:

  • 可扩展更多身份属性
  • 适合跨应用身份携带

缺点:

  • 体系更复杂
  • 落地成本高

适用:

  • 多系统统一身份平台
  • 需要可验证凭证的 B2B/B2G 场景

本文选择

本文采用 方案一,因为它在工程上最平衡:

  • 安全性足够高
  • 用户体验相对最好
  • 最容易和现有 Web 后端融合

实战代码(可运行)

下面我们用一套最小可运行方案实现:

  • 后端:Node.js + Express + ethers
  • 前端:原生 HTML + ethers.js
  • 存储:为了示例简单,用内存 Map;生产环境应换 Redis / DB

后端实现

1. 初始化项目

mkdir web3-auth-demo
cd web3-auth-demo
npm init -y
npm install express cors jsonwebtoken ethers dotenv

2. 目录结构

web3-auth-demo/
├─ server.js
├─ .env
└─ public/
   └─ index.html

3. 环境变量

JWT_SECRET=replace_me_with_a_strong_secret
PORT=3000

4. 服务端代码

// server.js
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const { ethers } = require("ethers");
const path = require("path");

const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));

const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET || "dev_secret_change_me";

// 模拟存储:生产环境请换成 Redis / DB
const nonceStore = new Map(); // address -> { nonce, message, expiresAt, used }
const userStore = new Map();  // address -> { address, createdAt, lastLoginAt }

// 统一做地址规范化
function normalizeAddress(address) {
  return ethers.getAddress(address);
}

function generateNonce() {
  return crypto.randomBytes(16).toString("hex");
}

function buildLoginMessage({ domain, address, nonce, chainId }) {
  const issuedAt = new Date().toISOString();
  return [
    `Welcome to ${domain}`,
    ``,
    `Address: ${address}`,
    `Nonce: ${nonce}`,
    `Chain ID: ${chainId}`,
    `Issued At: ${issuedAt}`,
    `Statement: Sign this message to authenticate.`
  ].join("\n");
}

// 获取 nonce 和待签名消息
app.get("/auth/nonce", (req, res) => {
  try {
    const { address, chainId = 1 } = req.query;
    if (!address) {
      return res.status(400).json({ error: "address is required" });
    }

    const normalized = normalizeAddress(address);
    const nonce = generateNonce();
    const message = buildLoginMessage({
      domain: req.hostname || "localhost",
      address: normalized,
      nonce,
      chainId
    });

    nonceStore.set(normalized, {
      nonce,
      message,
      expiresAt: Date.now() + 5 * 60 * 1000,
      used: false
    });

    return res.json({
      address: normalized,
      nonce,
      message,
      expiresIn: 300
    });
  } catch (err) {
    return res.status(400).json({ error: "invalid address" });
  }
});

// 验签并签发 JWT
app.post("/auth/verify", async (req, res) => {
  try {
    const { address, message, signature } = req.body;

    if (!address || !message || !signature) {
      return res.status(400).json({ error: "address, message, signature are required" });
    }

    const normalized = normalizeAddress(address);
    const nonceRecord = nonceStore.get(normalized);

    if (!nonceRecord) {
      return res.status(400).json({ error: "nonce not found" });
    }

    if (nonceRecord.used) {
      return res.status(400).json({ error: "nonce already used" });
    }

    if (Date.now() > nonceRecord.expiresAt) {
      return res.status(400).json({ error: "nonce expired" });
    }

    if (nonceRecord.message !== message) {
      return res.status(400).json({ error: "message mismatch" });
    }

    const recoveredAddress = ethers.verifyMessage(message, signature);
    const recoveredNormalized = normalizeAddress(recoveredAddress);

    if (recoveredNormalized !== normalized) {
      return res.status(401).json({ error: "signature verification failed" });
    }

    nonceRecord.used = true;
    nonceStore.set(normalized, nonceRecord);

    const now = new Date().toISOString();
    const exists = userStore.get(normalized);
    if (exists) {
      exists.lastLoginAt = now;
      userStore.set(normalized, exists);
    } else {
      userStore.set(normalized, {
        address: normalized,
        createdAt: now,
        lastLoginAt: now
      });
    }

    const token = jwt.sign(
      { sub: normalized, type: "access" },
      JWT_SECRET,
      { expiresIn: "2h" }
    );

    return res.json({
      token,
      user: userStore.get(normalized)
    });
  } catch (err) {
    return res.status(500).json({ error: err.message || "internal error" });
  }
});

function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization || "";
  const token = authHeader.startsWith("Bearer ")
    ? authHeader.slice(7)
    : null;

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

  try {
    const payload = jwt.verify(token, JWT_SECRET);
    req.user = { address: payload.sub };
    next();
  } catch (err) {
    return res.status(401).json({ error: "invalid token" });
  }
}

app.get("/me", authMiddleware, (req, res) => {
  const user = userStore.get(req.user.address);
  return res.json({
    address: req.user.address,
    profile: user || null
  });
});

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

前端实现

1. 页面代码

<!-- public/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Web3 Auth Demo</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      max-width: 760px;
      margin: 40px auto;
      line-height: 1.6;
      padding: 0 16px;
    }
    button {
      padding: 10px 16px;
      margin-right: 10px;
      cursor: pointer;
    }
    pre {
      background: #f5f5f5;
      padding: 12px;
      overflow: auto;
      border-radius: 8px;
    }
  </style>
</head>
<body>
  <h1>钱包登录 Demo</h1>
  <p>请先安装 MetaMask,并切换到任意 EVM 链。</p>

  <button id="connectBtn">连接钱包并登录</button>
  <button id="profileBtn">获取当前用户信息</button>

  <h3>状态</h3>
  <pre id="output">尚未开始</pre>

  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/ethers.umd.min.js"></script>
  <script>
    const output = document.getElementById("output");
    const connectBtn = document.getElementById("connectBtn");
    const profileBtn = document.getElementById("profileBtn");

    function log(data) {
      output.textContent =
        typeof data === "string" ? data : JSON.stringify(data, null, 2);
    }

    async function connectAndLogin() {
      try {
        if (!window.ethereum) {
          throw new Error("未检测到钱包,请安装 MetaMask");
        }

        const provider = new ethers.BrowserProvider(window.ethereum);
        await provider.send("eth_requestAccounts", []);
        const signer = await provider.getSigner();
        const address = await signer.getAddress();

        const network = await provider.getNetwork();
        const chainId = network.chainId.toString();

        log({ step: "wallet_connected", address, chainId });

        const nonceResp = await fetch(`/auth/nonce?address=${address}&chainId=${chainId}`);
        const nonceData = await nonceResp.json();

        if (!nonceResp.ok) {
          throw new Error(nonceData.error || "获取 nonce 失败");
        }

        log({ step: "nonce_received", nonceData });

        const signature = await signer.signMessage(nonceData.message);

        log({ step: "message_signed", signature });

        const verifyResp = await fetch("/auth/verify", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            address,
            message: nonceData.message,
            signature
          })
        });

        const verifyData = await verifyResp.json();
        if (!verifyResp.ok) {
          throw new Error(verifyData.error || "验签失败");
        }

        localStorage.setItem("token", verifyData.token);
        log({
          step: "login_success",
          user: verifyData.user,
          token: verifyData.token
        });
      } catch (err) {
        log({ step: "error", message: err.message });
      }
    }

    async function fetchProfile() {
      try {
        const token = localStorage.getItem("token");
        if (!token) {
          throw new Error("请先登录");
        }

        const resp = await fetch("/me", {
          headers: {
            Authorization: `Bearer ${token}`
          }
        });

        const data = await resp.json();
        if (!resp.ok) {
          throw new Error(data.error || "获取用户信息失败");
        }

        log({ step: "profile_loaded", data });
      } catch (err) {
        log({ step: "error", message: err.message });
      }
    }

    connectBtn.addEventListener("click", connectAndLogin);
    profileBtn.addEventListener("click", fetchProfile);
  </script>
</body>
</html>

2. 启动项目

node server.js

然后浏览器打开:

http://localhost:3000

3. 运行链路验证清单

你可以按这个顺序验证:

  1. 点击“连接钱包并登录”
  2. 钱包弹出账户授权
  3. 服务端生成 nonce 和 message
  4. 钱包弹出签名确认
  5. 服务端验签成功并返回 JWT
  6. 点击“获取当前用户信息”
  7. 携带 JWT 成功访问受保护接口

状态模型:一次登录请求的生命周期

这套认证过程本质上是一个状态流转模型。把状态想清楚,很多 bug 就会少一半。

stateDiagram-v2
  [*] --> WalletConnected
  WalletConnected --> NonceIssued
  NonceIssued --> Signed
  Signed --> Verified
  Verified --> SessionActive
  NonceIssued --> Expired
  Signed --> Rejected
  Expired --> [*]
  Rejected --> [*]
  SessionActive --> [*]

核心实现细节拆解

1. 为什么服务端要保存 message,而不是只保存 nonce?

很多教程只存 nonce,验签时再拼接 message。
这在简单场景能跑,但有两个风险:

  • 前后端拼接逻辑稍有差异就会导致验签失败
  • 时间戳、换行、空格、字段顺序都可能影响签名结果

所以我更推荐:

  • 服务端生成完整 message
  • 原样返回前端
  • 验签时要求前端回传相同 message
  • 服务端比对 message 是否完全一致

这样可以显著减少“明明签了,为什么验不过”的问题。

2. 地址规范化非常关键

EVM 地址理论上不区分大小写,但很多库会输出 checksum 地址。
如果你的数据库里有的是小写,有的是 checksum,很容易出现:

  • 查不到用户
  • 字符串比较失败
  • 同一个地址被当成两个用户

这里建议统一使用:

ethers.getAddress(address)

它会输出标准 checksum 格式,便于全链路统一。

3. JWT 只是会话载体,不是身份根

真正的身份根是钱包私钥控制权,JWT 只是“本次登录认证通过后,服务端签发的会话凭证”。

也就是说:

  • 钱包签名负责首次认证
  • JWT 负责后续请求复用登录状态

不要把 JWT 当成“真正的 Web3 身份”。


常见坑与排查

这一节我尽量写得接地气一点,因为这些问题真的很常见,而且不少我自己也踩过。

坑 1:前端签名方法和后端验签方法不匹配

比如前端用的是:

signer.signMessage(message)

后端就应该用:

ethers.verifyMessage(message, signature)

如果你前端改成了 EIP-712 typed data 签名,那后端也必须换对应的恢复方式,不能混用。

排查方法:

  • 先确认前端调用的是 signMessage 还是 signTypedData
  • 再确认后端使用的是哪种验签函数

坑 2:消息内容被无意改动

最典型的是换行符问题:

  • 前端 \n
  • 后端 Windows 环境变成 \r\n

或者前端自己重新拼了一遍 message,字段顺序不一致。

排查方法:

  • 服务端返回完整 message
  • 前端直接签服务端返回内容,不自行重构
  • 出问题时,把待签名字符串逐字符打印出来比对

坑 3:nonce 没有失效机制

如果 nonce 没有:

  • 过期时间
  • 单次使用标记

那就等于把系统暴露给重放攻击。

正确做法:

  • nonce 设置 5 分钟左右有效期
  • 验签成功后立刻标记为已使用
  • 最好存 Redis,天然适合短期状态

坑 4:用户切换钱包账户后,前端仍保留旧 token

这个问题在实际项目里很常见。
用户登录后切到了另一个地址,但前端本地还拿着旧地址 token,结果:

  • 页面展示地址 A
  • 钱包实际是地址 B
  • 用户以为自己登录的是 B,后台却识别成 A

排查方法:

监听钱包事件:

window.ethereum.on("accountsChanged", () => {
  localStorage.removeItem("token");
  window.location.reload();
});

window.ethereum.on("chainChanged", () => {
  localStorage.removeItem("token");
  window.location.reload();
});

坑 5:把“是否连接钱包”误当成“是否登录”

连接钱包只能说明浏览器能访问钱包,不能说明用户已经完成认证。

正确认知:

  • eth_requestAccounts = 获取地址
  • signMessage + 服务端验签成功 = 完成登录

这是两个阶段,别混为一谈。


坑 6:反向代理后 req.hostname 不准确

如果你的服务部署在 Nginx / CDN / API Gateway 后面,服务端生成 message 时写入的 domain 可能不是真实外部域名。

建议:

  • 从配置中读取固定 domain
  • 不要依赖运行时 req.hostname 推断

安全最佳实践

这一部分建议你真正带回项目里用,因为它决定了这套系统是不是“能上线”。

1. 使用 SIWE 思路组织消息

虽然本文没完整引入 SIWE(Sign-In with Ethereum)标准库,但强烈建议你按它的结构设计消息字段。至少包含:

  • domain
  • address
  • statement
  • uri
  • version
  • chainId
  • nonce
  • issuedAt
  • expirationTime(可选)

这样未来要接入标准生态会更顺畅。

2. nonce 存 Redis,不要只存在进程内存

本文示例为了可运行性用了 Map,但生产环境一定要注意:

  • 多实例部署时内存不共享
  • 服务重启后 nonce 丢失
  • 不便于统一过期控制

更靠谱的做法是:

  • SETEX auth:nonce:{address} value 300
  • 验签成功后原子删除或标记已使用

3. JWT 不要长期有效

推荐:

  • Access Token:15 分钟到 2 小时
  • Refresh Token:按业务决定是否需要

如果业务并不频繁请求接口,甚至可以不做 refresh,直接过期后重新签名登录,逻辑更简单也更安全。

4. 对关键字段做强校验

至少要校验:

  • 地址格式是否合法
  • nonce 是否存在、未使用、未过期
  • message 是否与服务端原始版本完全一致
  • 恢复地址是否与目标地址匹配
  • chainId 是否符合你的业务支持范围

5. 防止接口被滥刷

/auth/nonce 是公开接口,容易成为刷接口目标。建议加上:

  • IP 级别限流
  • 地址级别限流
  • User-Agent 风险识别
  • WAF / CDN 基础防护

6. 对高价值操作做二次签名

登录签名只能证明“你是谁”,不能自动代表“你授权做任何事”。

对于高风险操作,比如:

  • 提现
  • 修改收款地址
  • 委托授权
  • 铸造关键资产

建议重新要求用户签一条带业务语义和有效期的操作消息,不要只依赖登录态。


性能与容量估算

认证服务通常不是最重的业务模块,但如果面向大规模活动流量,还是值得做个粗略估算。

请求拆分

一次完整登录通常至少包含:

  1. 获取 nonce
  2. 提交签名验签

如果算上后续获取用户资料,就是 2~3 个请求。

服务端开销

验签本身是纯 CPU 密码学计算,通常开销不算大,但当并发较高时仍需注意:

  • verifyMessage 会消耗 CPU
  • JWT 签发也有少量 CPU 开销
  • 更大的瓶颈往往是 Redis / DB 和网关限流配置

一个实用估算模型

假设:

  • 峰值同时登录用户:2000
  • 每个登录 2 个认证请求
  • 峰值窗口 1 分钟

则 QPS 大约为:

2000 * 2 / 60 ≈ 67 QPS

对 Node.js 单体服务来说,这个量级通常不大。
但如果你是活动型产品,比如 NFT mint 前置登录、空投任务、抢白名单,那瞬时峰值会更集中,建议:

  • 认证服务单独部署
  • nonce 放 Redis
  • 增加网关限流和熔断
  • JWT 尽量无状态化,减少 DB 读写

可扩展架构:从 EOA 走向更完整的身份体系

如果你后续要把这套系统扩展成更强的身份平台,可以按这个方向演进:

flowchart TD
  A[钱包签名登录 EOA] --> B[标准化消息 SIWE]
  B --> C[支持 EIP-1271 合约钱包]
  C --> D[DID 标识映射]
  D --> E[VC 可验证凭证]
  E --> F[跨应用身份聚合]

一个务实的演进顺序

我通常建议团队这样推进:

  1. 先完成 EOA 钱包签名登录
  2. 把消息结构标准化到 SIWE 风格
  3. 补上合约钱包支持
  4. 再考虑 DID/VC 体系

原因很简单:
如果第一步都没稳定,后面所有“更去中心化”的设计都只是复杂度叠加。


生产落地建议

什么时候适合上线这套方案?

适合:

  • 用户通过钱包地址完成身份识别
  • 业务后端需要登录态
  • 接口权限控制仍由服务端主导
  • 用户主要来自 EVM 钱包生态

什么时候不够用?

不太够用的场景包括:

  • 你需要支持合约钱包为主的用户群
  • 你要实现跨链统一身份聚合
  • 你要支持企业级可验证凭证
  • 你需要极高等级的抗钓鱼与抗重放能力

这时候就需要进一步考虑:

  • SIWE 标准化
  • EIP-1271
  • Typed Data 签名
  • DID / VC
  • 风险引擎和设备绑定

总结

这套“钱包登录 + 链下签名 + 服务端验签 + JWT 会话”的方案,是很多 Web3 应用里最有工程性价比的身份认证路径。

你可以把它理解成三层:

  1. 钱包地址:身份标识
  2. 签名挑战:控制权证明
  3. JWT 会话:业务系统内的访问凭证

真正关键的,不是把签名接口调通,而是把细节做对:

  • 一次性 nonce,防止重放
  • 服务端生成并保存完整 message
  • 地址格式统一规范化
  • 登录态与钱包状态解耦但保持同步
  • 高风险操作二次签名
  • 生产环境使用 Redis、限流、短期 token

如果你现在正准备把 Web2 登录系统迁到 Web3,我的建议是:

  • 第一阶段:先上本文这套最小闭环
  • 第二阶段:向 SIWE 结构靠拢
  • 第三阶段:补合约钱包与更丰富的身份声明

别一开始就试图做“终极去中心化身份平台”。
先把登录链路做稳、做清楚、做可观测,才是真正能支撑业务的架构。


分享到:

上一篇
《区块链中智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建-131》
下一篇
《从抓包到还原签名链路:中级开发者实战分析 Web 逆向中的前端加密与接口鉴权机制》