Web3 中级实战:从零搭建基于钱包登录与链上签名验证的去中心化身份认证系统
在传统 Web 应用里,登录这件事很“中心化”:用户名、密码、短信验证码,最终都落在服务端数据库和权限系统里。到了 Web3,用户天然已经有了“账户”——钱包地址。问题变成了:如何证明“这个地址真的是当前这个用户在控制”,并把这个证明接入我们的业务系统。
这篇文章我会从架构视角带你完整走一遍:不仅是“让 MetaMask 弹一下签名框”,而是搭一个可运行、可扩展、可排查问题的钱包登录与签名验证系统。重点放在:
- 如何设计钱包登录认证流
- 如何做服务端签名验证
- 如何避免重放攻击
- 如何在“去中心化身份”和“中心化会话管理”之间做好边界划分
如果你已经写过简单的 DApp 页面,但还没把“钱包地址 -> 认证身份 -> 后端会话”这一套真正打通,这篇比较适合你。
背景与问题
为什么钱包地址不能直接当“已登录”凭证?
很多人刚接触 Web3 时会有一个误区:
前端拿到了
eth_requestAccounts返回的地址,不就说明用户登录了吗?
其实并不是。
原因很简单:地址是公开信息,不是身份证明本身。任何人都可以声称“我是 0x123…”,但只有掌握私钥的人,才能对指定消息完成签名。因此,真正的认证链路应该是:
- 前端请求钱包地址
- 服务端生成一次性挑战消息(nonce)
- 用户用钱包对挑战消息签名
- 服务端验证签名,确认地址控制权
- 服务端签发业务会话(JWT / Session)
也就是说,钱包负责“密码学证明”,后端负责“业务态登录”。
这个问题本质上是在解决什么?
从架构角度看,它解决的是三个层面的事情:
- 身份声明:用户声称自己拥有某个钱包地址
- 所有权证明:用户对随机消息完成签名,证明控制该地址
- 业务接入:系统将链上身份映射为站内用户与权限
常见错误方案
我见过不少项目一开始这么做,后面几乎都要返工:
- 仅前端保存钱包地址,后端完全不验证
- 直接让用户签固定文案,没有 nonce
- nonce 不失效,可被重复使用
- 验签成功后不发会话,所有请求都要求前端重复签名
- 没有链 ID、域名、时间戳约束,签名可被跨站滥用
这些问题的共同点是:把“签名”当成了交互动作,而不是完整的认证协议的一部分。
方案总览与架构设计
我们先看一个适合中小型 Web3 应用的认证架构。
flowchart TD
A[前端 DApp] --> B[请求 nonce]
B --> C[后端 Auth 服务]
C --> D[(Redis/DB 存储 nonce)]
A --> E[钱包签名]
E --> A
A --> F[提交 address + message + signature]
F --> C
C --> G[验签]
G --> H[签发 JWT / Session]
H --> I[(用户表 / 会话表)]
H --> A
A --> J[携带 Token 访问业务 API]
J --> K[业务服务]
这个架构里有两个关键边界:
边界一:链上身份 ≠ 站内业务身份
钱包地址只能证明“你控制这个地址”,不能自动代表:
- 你是管理员
- 你有某个业务角色
- 你拥有某个订阅权益
- 你一定要上链创建用户资料
因此,通常我们会有一张用户表,把钱包地址映射为站内用户:
user_id -> wallet_address -> role -> profile -> permissions
边界二:不必把认证写到链上
题目里提到“链上签名验证”,这里需要澄清一个很容易混淆的点:
- 常见登录验签:服务端本地用
ecrecover逻辑验证签名,不需要真的发链上交易 - 链上验证合约:把签名和消息提交给合约,由合约验证签名
对于大多数 Web 登录场景,本地验签更合理,成本低、响应快。只有当你的业务要求“验证结果必须被链上状态消费”时,才需要把验签逻辑放到合约里。
核心原理
1. ECDSA 签名验证
以以太坊钱包为例,用户对消息签名,本质上是对消息哈希进行 ECDSA 签名。服务端可以通过签名恢复出公钥对应地址,再和用户声明的地址比对。
核心流程:
- 服务端生成 challenge message
- 用户钱包签名
- 服务端通过
verifyMessage恢复签名地址 - 比对地址是否一致
2. Nonce 防重放
如果没有 nonce,攻击者只要拿到一份历史签名,就能无限复用。正确做法是:
- nonce 随机生成
- 与地址绑定
- 验签成功后立即作废
- 设置短期过期时间,比如 5 分钟
3. 域隔离与上下文绑定
一个好的签名消息至少应该包含:
- 站点域名
- 钱包地址
- nonce
- 签发时间
- 过期时间
- chainId
- 用途说明
这样即使用户在别的站点也签过类似消息,攻击者也难以直接复用。
4. 会话层的必要性
验证签名只是“登录动作”。后续每次 API 请求都要求用户重新签名,体验会非常差。所以通常流程是:
- 首次登录:钱包签名
- 后续访问:JWT / HttpOnly Cookie
这是 Web3 应用经常被忽略的一点:Web3 身份证明与 Web2 会话管理可以组合,而不是互斥。
认证时序图
sequenceDiagram
participant U as 用户
participant W as 钱包
participant F as 前端
participant S as 认证服务
U->>F: 点击“钱包登录”
F->>W: 请求连接钱包
W-->>F: 返回 address
F->>S: GET /auth/nonce?address=0x...
S-->>F: 返回 message + nonce
F->>W: 请求签名 personal_sign
W-->>F: 返回 signature
F->>S: POST /auth/verify
Note over S: 校验 nonce/过期时间/签名地址
S-->>F: 返回 JWT
F->>S: 携带 JWT 访问业务接口
S-->>F: 返回受保护资源
方案对比与取舍分析
方案 A:前端拿地址即登录
优点:
- 实现最快
- 几乎没有后端改造
缺点:
- 没有真正的身份证明
- 容易被伪造
- 无法安全支持受保护接口
结论: 只能做 Demo,不能上生产。
方案 B:钱包签名 + 服务端本地验签
优点:
- 安全性和成本比较平衡
- 性能高,响应快
- 易于与现有用户系统整合
缺点:
- 需要自己管理 nonce、会话、风控
- 多钱包、多链兼容需要更多工程细节
结论: 大多数 Web3 应用的主流选项。
方案 C:链上合约验签 + 链下登录映射
优点:
- 验证逻辑可公开审计
- 某些需要链上状态消费的场景更自然
缺点:
- 成本高
- 延迟高
- 登录流程复杂
- 没必要为普通 Web 登录强行上链
结论: 适合链上治理、合约授权类场景,不适合通用站点登录。
实战代码(可运行)
下面我们做一个最小可运行版本:
- 前端:HTML + ethers.js
- 后端:Node.js + Express + ethers + jsonwebtoken
- 存储:内存版 nonce(方便本地演示),生产请换 Redis
项目结构
web3-auth-demo/
├─ server.js
├─ package.json
└─ public/
└─ index.html
安装依赖
mkdir web3-auth-demo
cd web3-auth-demo
npm init -y
npm install express ethers jsonwebtoken cors
后端代码:server.js
const express = require("express");
const cors = require("cors");
const jwt = require("jsonwebtoken");
const { ethers } = require("ethers");
const path = require("path");
const app = express();
const PORT = 3000;
const JWT_SECRET = "replace-this-in-production";
// 演示用内存存储,生产环境请使用 Redis
const nonceStore = new Map();
// 生成随机 nonce
function generateNonce() {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
// 构造签名消息
function buildMessage({ domain, address, nonce, chainId, issuedAt, expirationTime }) {
return `${domain} wants you to sign in with your Ethereum account:
${address}
Sign in to the app.
URI: http://localhost:3000
Version: 1
Chain ID: ${chainId}
Nonce: ${nonce}
Issued At: ${issuedAt}
Expiration Time: ${expirationTime}`;
}
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
// 获取 nonce 与待签名消息
app.get("/auth/nonce", (req, res) => {
const { address, chainId = 1 } = req.query;
if (!address || !ethers.isAddress(address)) {
return res.status(400).json({ error: "Invalid address" });
}
const nonce = generateNonce();
const issuedAt = new Date().toISOString();
const expirationTime = new Date(Date.now() + 5 * 60 * 1000).toISOString();
const message = buildMessage({
domain: "localhost:3000",
address: ethers.getAddress(address),
nonce,
chainId,
issuedAt,
expirationTime,
});
nonceStore.set(ethers.getAddress(address), {
nonce,
message,
expiresAt: Date.now() + 5 * 60 * 1000,
});
res.json({ message, nonce });
});
// 验证签名并签发 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: "Missing required fields" });
}
const normalizedAddress = ethers.getAddress(address);
const record = nonceStore.get(normalizedAddress);
if (!record) {
return res.status(400).json({ error: "Nonce not found" });
}
if (Date.now() > record.expiresAt) {
nonceStore.delete(normalizedAddress);
return res.status(400).json({ error: "Nonce expired" });
}
if (record.message !== message) {
return res.status(400).json({ error: "Message mismatch" });
}
const recoveredAddress = ethers.verifyMessage(message, signature);
if (ethers.getAddress(recoveredAddress) !== normalizedAddress) {
return res.status(401).json({ error: "Invalid signature" });
}
// 验证成功后立即销毁 nonce,防止重放
nonceStore.delete(normalizedAddress);
// 模拟站内用户
const user = {
walletAddress: normalizedAddress,
role: "user",
};
const token = jwt.sign(user, JWT_SECRET, { expiresIn: "2h" });
res.json({
success: true,
token,
user,
});
} catch (err) {
console.error(err);
res.status(500).json({ error: "Verification failed" });
}
});
// 受保护接口
app.get("/me", (req, res) => {
const auth = req.headers.authorization || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (!token) {
return res.status(401).json({ error: "Missing token" });
}
try {
const payload = jwt.verify(token, JWT_SECRET);
res.json({
authenticated: true,
user: payload,
});
} catch (err) {
res.status(401).json({ error: "Invalid token" });
}
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
前端代码: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>
</head>
<body>
<h1>Web3 钱包登录演示</h1>
<button id="loginBtn">连接钱包并登录</button>
<button id="profileBtn">获取当前用户信息</button>
<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");
function log(data) {
output.textContent = typeof data === "string" ? data : JSON.stringify(data, null, 2);
}
async function login() {
if (!window.ethereum) {
log("请先安装 MetaMask");
return;
}
try {
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 = Number(network.chainId);
// 1. 向服务端请求待签名消息
const nonceResp = await fetch(`http://localhost:3000/auth/nonce?address=${address}&chainId=${chainId}`);
const nonceData = await nonceResp.json();
if (!nonceResp.ok) {
throw new Error(nonceData.error || "获取 nonce 失败");
}
// 2. 钱包签名
const signature = await signer.signMessage(nonceData.message);
// 3. 提交验签
const verifyResp = await fetch("http://localhost:3000/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({
message: "登录成功",
...verifyData,
});
} catch (err) {
log(`错误:${err.message}`);
}
}
async function getProfile() {
const token = localStorage.getItem("token");
if (!token) {
log("请先登录");
return;
}
const resp = await fetch("http://localhost:3000/me", {
headers: {
Authorization: `Bearer ${token}`,
},
});
const data = await resp.json();
log(data);
}
document.getElementById("loginBtn").addEventListener("click", login);
document.getElementById("profileBtn").addEventListener("click", getProfile);
</script>
</body>
</html>
运行方式
node server.js
浏览器访问:
http://localhost:3000
点击“连接钱包并登录”,完成签名后即可拿到 JWT,再点击“获取当前用户信息”验证会话。
如果你需要“链上验签”而不是本地验签
上面的登录方案已经足够支撑绝大多数认证系统。但如果你的业务要求“合约里也要验证这个签名”,可以使用 Solidity 的 ECDSA 库。
示例合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SignatureVerifier {
using ECDSA for bytes32;
function getMessageHash(string memory message) public pure returns (bytes32) {
return keccak256(abi.encodePacked(message));
}
function verify(
address signer,
string memory message,
bytes memory signature
) public pure returns (bool) {
bytes32 messageHash = getMessageHash(message);
bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();
return ethSignedMessageHash.recover(signature) == signer;
}
}
什么时候值得用链上验签?
更适合这类场景:
- 合约授权操作
- 链上投票
- Permit / meta transaction
- 需要链上状态直接消费签名结果
如果只是网站登录,我的建议很明确:别为了“去中心化”而把登录也搞成链上交易,你会平白引入 gas 成本和确认延迟。
状态与失败路径建模
这部分在实际项目里很有用,因为登录系统出问题时,往往不是“完全不能用”,而是卡在某个中间状态。
stateDiagram-v2
[*] --> Disconnected
Disconnected --> WalletConnected: 连接钱包
WalletConnected --> NonceIssued: 请求 nonce 成功
NonceIssued --> Signed: 用户完成签名
NonceIssued --> Cancelled: 用户取消签名
Signed --> Verified: 服务端验签成功
Signed --> Failed: 验签失败
Verified --> SessionActive: 签发 JWT
SessionActive --> Expired: Token 过期
Failed --> Disconnected
Cancelled --> WalletConnected
Expired --> WalletConnected
常见坑与排查
这部分我建议你在开发时直接对照检查,能省掉很多“明明签了却验不过”的时间。
1. 地址大小写不一致
以太坊地址有 checksum 格式。如果前端传上来的是小写地址,而后端恢复的是 checksum 地址,直接字符串比较可能失败。
解决办法:
统一使用:
ethers.getAddress(address)
做标准化处理。
2. 前后端签名消息不完全一致
这是最常见的坑之一。我当时第一次接的时候,也是在这里卡了半天。
比如这些细节都可能导致验签失败:
- 少一个换行
- 多一个空格
- 前端签的是
message + "\n" - 后端重新拼接 message,但字段顺序不同
建议:
- 服务端生成完整 message
- 前端只负责原样签名
- 验签时比对 message 原文,不要在后端“重新猜”一遍
3. nonce 没及时失效,导致重放攻击
如果一个签名可重复提交,那么别人截获请求后就能冒用。
排查点:
- 验签成功后是否删除 nonce
- nonce 是否设置 TTL
- 同一地址是否允许并发多个 nonce
建议:
生产里用 Redis,并为 nonce 设置:
- 单地址单有效 nonce
- 5 分钟过期
- 验签成功立即删除
4. 使用了错误的签名方法
常见签名方式包括:
personal_signeth_signsignTypedData_v4
不同方式验签逻辑不完全一样。本文示例使用的是 signMessage,对应以太坊前缀消息签名。
建议:
登录认证优先用:
signer.signMessage(message)
如果要做更标准化的登录,可进一步升级到 EIP-4361(SIWE)。
5. 切链导致上下文不一致
用户在 Polygon 上连接钱包,但后端消息写的是 Ethereum Mainnet 的 chainId,虽然签名本身可能仍然成立,但业务语义已经不一致。
建议:
- 把 chainId 写入 challenge message
- 验签后校验该值是否符合业务要求
- 某些业务场景下限制仅支持特定链
6. JWT 放在 localStorage 的风险
本文为了演示简单,token 放在了 localStorage。但在生产环境中,这会扩大 XSS 风险。
更稳妥的方案:
- 使用 HttpOnly Cookie 存储会话
- 配合 CSRF 防护
- 对前端进行严格 CSP 限制
安全最佳实践
1. 使用标准化消息格式
如果准备长期维护,我建议直接采用 SIWE(Sign-In with Ethereum, EIP-4361)。它解决了:
- 域名绑定
- nonce
- 时间字段
- 资源声明
- 标准消息结构
这样钱包登录不再是“你自己定义的一段字符串”,而是更像协议化的身份声明。
2. nonce 存 Redis,不存前端
nonce 必须由后端生成和管理。不要相信前端自己传来的随机值。
推荐 Redis key 设计:
auth:nonce:{walletAddress}
值中可包含:
{
"nonce": "abc123",
"message": "...",
"expiresAt": 1640352000000
}
3. 限流与风控
虽然签名登录不怕撞库,但仍然需要防刷:
- 按 IP 限流 nonce 请求
- 按地址限流验签请求
- 对异常频率做告警
- 记录失败签名样本便于排查
4. 域名、URI、Chain ID 必须校验
不要只验证“签名对不对”,还要验证“签的到底是不是你这个站的消息”。
建议校验:
- 域名是否是本站
- URI 是否为预期来源
- chainId 是否在支持列表
- issuedAt / expirationTime 是否有效
5. 钱包地址只做身份锚点,不直接承载业务权限
权限系统仍然应该在后端维护:
wallet -> useruser -> rolerole -> permission
这样当你后续需要支持:
- 一个用户绑定多个钱包
- 钱包更换
- 子账号体系
- DAO 成员身份同步
系统不会推倒重来。
性能最佳实践
登录系统看起来流量不大,但在活动场景下往往会突然放量,比如 NFT mint 前、空投活动、白名单校验时。
1. 验签本地完成,避免链上调用
本地验签是纯 CPU 操作,比链上 RPC 或合约调用更轻量。绝大多数登录场景都应该这样做。
2. nonce 使用 Redis,避免数据库热点写入
nonce 是高频、短生命周期数据,放 MySQL 不划算。Redis 更适合:
- 自动过期
- 高并发
- 易于原子删除
3. JWT 尽量短期,配合刷新机制
推荐:
- access token:15 分钟 ~ 2 小时
- refresh token:更长,但要可吊销
如果系统对安全要求高,建议用服务端 session 存储替代完全无状态 JWT。
4. 容量估算思路
假设高峰期:
- 每分钟 3000 次登录挑战请求
- 每次 nonce 占用约 500B ~ 1KB
- 有效期 5 分钟
那么 Redis 同时在库 nonce 数量大约:
3000 * 5 = 15000
按 1KB 粗估,约 15MB 量级,完全可控。
真正更需要关注的是:
- 钱包签名交互延迟
- 前端重试策略
- 验签接口的限流与幂等
面向生产的改进建议
如果你准备把这个 Demo 升级成生产可用系统,我建议按这个顺序演进:
第一步:替换内存 nonce 为 Redis
因为服务多实例部署后,内存 Map 无法共享。
第二步:引入 SIWE
减少自定义消息格式导致的兼容和安全问题。
第三步:使用 HttpOnly Cookie
降低 token 被脚本窃取的风险。
第四步:支持多钱包与多链
例如:
- MetaMask
- WalletConnect
- Coinbase Wallet
并建立支持链白名单。
第五步:补充账户映射层
将钱包地址映射到站内用户中心,而不是让钱包地址直接充当所有业务主键。
总结
从架构上看,一个靠谱的 Web3 身份认证系统,核心不是“让用户签个名”这么简单,而是把这条链路拆清楚:
- 钱包地址负责身份声明
- 签名负责所有权证明
- 后端负责会话签发与权限管理
最实用、也最平衡的落地方案依然是:
- 前端连接钱包获取地址
- 后端生成一次性 nonce 和待签名消息
- 用户钱包签名
- 服务端本地验签
- 验签成功后发 JWT 或 Session
如果你只记住一句话,我希望是这个:
Web3 登录不是“去掉后端认证”,而是“把密码换成了链上私钥控制权证明”。
最后给几个可执行建议,适合你直接用于项目:
- Demo 阶段:按本文示例跑通完整登录链路
- 测试环境:把 nonce 改成 Redis,增加过期与日志
- 生产环境:引入 SIWE、HttpOnly Cookie、限流、域校验
- 复杂业务:把钱包身份与站内权限彻底解耦
边界条件也很明确:
- 如果只是普通网站登录,不要强行把验签放链上
- 如果需要合约消费签名结果,再考虑 Solidity 验签
- 如果未来要支持多链、多钱包、多身份绑定,尽早设计用户映射层
这套系统搭好后,你的 Web3 应用才算真正跨过了“能连钱包”和“能做认证”的分水岭。