Web3 中级实战:从零搭建基于钱包登录与链上签名的去中心化身份认证系统
在传统 Web 应用里,身份认证通常绕不开“账号 + 密码 + Session/JWT”这套组合拳。但到了 Web3 场景,用户未必愿意注册账号,更不想把密码托管给某个中心化服务。更常见的诉求是:直接使用钱包地址作为身份入口,并通过签名证明“这个地址现在由我控制”。
这篇文章我会从架构角度,带你搭建一套中等复杂度、可实际运行的去中心化身份认证系统。它不是只讲原理,也不是只贴几段零散代码,而是从背景、协议设计、前后端实现、风险点与最佳实践一路串起来,最终形成一套完整可落地的方案。
背景与问题
为什么“钱包地址 = 身份”还不够?
很多刚接触 Web3 的同学会有个误区:既然用户有钱包地址,那直接把地址当作用户 ID 不就行了?
问题是:地址是公开信息,不是身份证明。
我知道某个地址是 0xabc...,并不代表我控制这个地址。真正能证明控制权的,是:
- 使用私钥发起交易
- 或使用私钥对一段指定消息进行签名
而在“登录”场景里,我们通常不需要用户真正上链发交易,因为那样会产生 Gas 成本,也会增加交互负担。于是,**链下签名(off-chain signature)**就成了最适合的钱包登录方式。
这套系统要解决哪些问题?
一个可用的去中心化身份认证系统,至少要回答下面几个问题:
- 如何证明用户控制某个钱包地址?
- 如何防止签名被重放?
- 登录后如何维持会话?
- 如何兼容多钱包、多链环境?
- 如何避免把“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 兼容链为例,服务端通常会:
- 对消息按
personal_sign规范加前缀 - 使用椭圆曲线恢复公钥
- 从公钥推导出地址
- 与前端提交的地址比较
如果恢复出的地址一致,就说明签名确实由该地址对应私钥生成。
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. 运行链路验证清单
你可以按这个顺序验证:
- 点击“连接钱包并登录”
- 钱包弹出账户授权
- 服务端生成 nonce 和 message
- 钱包弹出签名确认
- 服务端验签成功并返回 JWT
- 点击“获取当前用户信息”
- 携带 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. 对高价值操作做二次签名
登录签名只能证明“你是谁”,不能自动代表“你授权做任何事”。
对于高风险操作,比如:
- 提现
- 修改收款地址
- 委托授权
- 铸造关键资产
建议重新要求用户签一条带业务语义和有效期的操作消息,不要只依赖登录态。
性能与容量估算
认证服务通常不是最重的业务模块,但如果面向大规模活动流量,还是值得做个粗略估算。
请求拆分
一次完整登录通常至少包含:
- 获取 nonce
- 提交签名验签
如果算上后续获取用户资料,就是 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[跨应用身份聚合]
一个务实的演进顺序
我通常建议团队这样推进:
- 先完成 EOA 钱包签名登录
- 把消息结构标准化到 SIWE 风格
- 补上合约钱包支持
- 再考虑 DID/VC 体系
原因很简单:
如果第一步都没稳定,后面所有“更去中心化”的设计都只是复杂度叠加。
生产落地建议
什么时候适合上线这套方案?
适合:
- 用户通过钱包地址完成身份识别
- 业务后端需要登录态
- 接口权限控制仍由服务端主导
- 用户主要来自 EVM 钱包生态
什么时候不够用?
不太够用的场景包括:
- 你需要支持合约钱包为主的用户群
- 你要实现跨链统一身份聚合
- 你要支持企业级可验证凭证
- 你需要极高等级的抗钓鱼与抗重放能力
这时候就需要进一步考虑:
- SIWE 标准化
- EIP-1271
- Typed Data 签名
- DID / VC
- 风险引擎和设备绑定
总结
这套“钱包登录 + 链下签名 + 服务端验签 + JWT 会话”的方案,是很多 Web3 应用里最有工程性价比的身份认证路径。
你可以把它理解成三层:
- 钱包地址:身份标识
- 签名挑战:控制权证明
- JWT 会话:业务系统内的访问凭证
真正关键的,不是把签名接口调通,而是把细节做对:
- 一次性 nonce,防止重放
- 服务端生成并保存完整 message
- 地址格式统一规范化
- 登录态与钱包状态解耦但保持同步
- 高风险操作二次签名
- 生产环境使用 Redis、限流、短期 token
如果你现在正准备把 Web2 登录系统迁到 Web3,我的建议是:
- 第一阶段:先上本文这套最小闭环
- 第二阶段:向 SIWE 结构靠拢
- 第三阶段:补合约钱包与更丰富的身份声明
别一开始就试图做“终极去中心化身份平台”。
先把登录链路做稳、做清楚、做可观测,才是真正能支撑业务的架构。