Web3 中级实战:从零搭建基于钱包登录与链上签名验证的去中心化身份认证系统
很多人第一次做 Web3 登录,都会觉得“这不就是让用户点一下钱包授权吗”。真上手后很快会发现:真正难的不是连上钱包,而是把“地址归属”变成一个可靠、可扩展、可审计的身份认证系统。
这篇文章我会站在架构设计 + 可运行实践的角度,带你从零搭一个中级可用的方案:前端使用钱包发起登录,后端生成挑战消息并验证签名,再下发会话令牌,形成一套去中心化身份认证流程。重点不是“能跑”,而是“为什么这么设计,以及哪里最容易出问题”。
背景与问题
在传统 Web 应用里,身份认证通常依赖:
- 用户名/密码
- 手机验证码
- 第三方 OAuth
- 服务端 Session
但在 Web3 场景里,用户往往没有用户名,最天然的身份标识是钱包地址。于是一个典型需求就出现了:
用户打开 DApp,点击“Connect Wallet”,签一段消息,后端验证签名,确认这个地址确实由当前用户控制,然后建立登录态。
听起来很直接,但如果你只做成“前端拿地址,后端信了”,那基本等于没做认证。因为:
- 钱包地址公开可见,不等于地址所有权证明
- 没有挑战消息(nonce)就容易被重放攻击
- 签名消息不规范,前后端很容易验签失败
- 登录态没有域名、时间、链 ID 约束,会产生跨站复用风险
- 合约钱包与 EOA 验证方式不完全一样
所以我们真正要解决的问题是:
- 如何证明“当前请求者持有该地址的私钥或控制权”
- 如何防止历史签名被重放
- 如何把钱包签名接入现有 Web 应用的会话体系
- 如何兼顾安全性、可维护性、性能与后续扩展
方案目标与架构边界
本文实现的目标是:
- 前端连接 MetaMask
- 请求后端生成一次性挑战消息
- 用户使用钱包签名
- 后端验证签名
- 验证通过后签发 JWT
- 后续接口使用 JWT 鉴权
这套方案适用于:
- DApp 后台管理系统
- 链上数据平台
- NFT / DAO 社区登录
- 需要“钱包即账号”的中后台服务
这套方案不直接覆盖:
- 合约钱包完整支持(文末会讲扩展)
- 多链统一身份聚合
- ENS / Lens / DID 文档解析
- 零知识身份认证
先把一条稳定主链打通,是中级阶段最划算的做法。
整体架构设计
从架构角度,我建议把钱包登录拆成四层:
- 钱包交互层:连接钱包、请求签名
- 认证协议层:挑战消息、nonce、域名、过期时间
- 签名验证层:恢复地址、比对地址、状态校验
- 会话管理层:JWT / Session、权限、续期、登出
架构总览
flowchart LR
U[用户] --> F[前端 DApp]
F --> W[钱包 MetaMask]
F --> B[后端 Auth API]
B --> R[(Redis/DB)]
B --> J[JWT 服务]
W -->|返回签名| F
B -->|存储 nonce/状态| R
B -->|签发 token| J
登录时序图
sequenceDiagram
participant U as User
participant F as Frontend
participant B as Backend
participant W as Wallet
participant R as Redis/DB
U->>F: 点击钱包登录
F->>W: 请求钱包地址
W-->>F: address
F->>B: POST /auth/nonce { address }
B->>R: 保存 nonce
B-->>F: 返回 message + nonce
F->>W: signMessage(message)
W-->>F: signature
F->>B: POST /auth/verify { address, message, signature }
B->>B: 验签并校验 nonce
B->>R: nonce 标记已使用
B-->>F: JWT
F->>B: 携带 JWT 请求业务接口
方案对比:为什么不用“只连接钱包就登录”
这是一个非常常见的取舍点。
方案 A:只拿钱包地址,不签名
优点:
- 实现最简单
- 用户点击成本最低
缺点:
- 完全无法证明地址归属
- 任何人都能伪造请求体中的 address
- 只能当“访客态标识”,不能当认证
方案 B:固定消息签名
例如永远让用户签:
Login to MyApp
优点:
- 比方案 A 强
- 实现简单
缺点:
- 容易被重放攻击
- 跨环境、跨时间复用签名风险高
- 无法追踪一次认证的上下文
方案 C:一次性挑战消息签名
消息中包含:
- domain
- address
- nonce
- issuedAt
- expirationTime
- chainId
- statement
优点:
- 防重放
- 可审计
- 易扩展
- 可以和 SIWE 思路对齐
缺点:
- 实现稍复杂
- 需要后端状态管理
我的建议
中级项目直接上 方案 C,不要在“省几行代码”上妥协。
因为你今天不做 nonce,明天就会在安全审计时补回来,而且补起来更疼。
核心原理
1. 钱包地址不是认证,签名才是认证
地址可以公开传播,私钥不能。
只要用户能对服务端指定消息进行签名,我们就能通过验签确认:当前用户确实控制这个地址。
2. 为什么必须有 nonce
如果你让用户签了消息:
我同意登录 MyApp
攻击者只要拿到这段签名,就可能在未来重复使用。
nonce 的作用就是:让每次登录消息都唯一且仅能使用一次。
3. 验签是怎么做的
以 EVM 链为例,常见流程是:
- 服务端构造消息
- 用户钱包签名
- 服务端使用
ethers.verifyMessage(message, signature)恢复签名地址 - 与用户提交的地址比较
- 地址一致则证明签名有效
4. 为什么还要 JWT
因为钱包签名很贵,不能每次接口请求都弹钱包。
所以通常做法是:
- 首次登录:钱包签名
- 登录成功:后端签发 JWT / Session
- 后续请求:使用 JWT
这就把 Web3 身份认证和传统 Web 会话管理结合起来了。
消息格式设计
建议消息中至少包含以下字段:
domain:当前站点域名address:钱包地址statement:登录用途说明uri:站点 URIversion:协议版本chainId:链 IDnonce:一次性随机串issuedAt:签发时间expirationTime:过期时间
示例:
myapp.com wants you to sign in with your Ethereum account:
0x1234...abcd
Sign in to MyApp.
URI: https://myapp.com
Version: 1
Chain ID: 1
Nonce: u8K1mPz9aQ
Issued At: 2024-06-01T12:00:00.000Z
Expiration Time: 2024-06-01T12:05:00.000Z
这类格式非常接近 SIWE(Sign-In with Ethereum),好处是规范、可读、可扩展。
实战代码(可运行)
下面我们用一个最小可运行方案:
- 前端:React + ethers
- 后端:Node.js + Express + ethers + jsonwebtoken
- 存储:内存 Map(演示用,生产改 Redis)
为了保证文章可直接跑,我会用尽量少的依赖。你也可以很容易迁移到 Next.js / NestJS。
项目结构
web3-auth-demo/
├─ server/
│ ├─ package.json
│ └─ index.js
└─ client/
├─ package.json
└─ src/
└─ App.jsx
后端实现
1. 安装依赖
mkdir server && cd server
npm init -y
npm install express cors ethers jsonwebtoken
2. 编写认证服务
// server/index.js
const express = require('express');
const cors = require('cors');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const { ethers } = require('ethers');
const app = express();
app.use(cors());
app.use(express.json());
const PORT = 3001;
const JWT_SECRET = 'replace-this-in-production';
// 演示使用内存存储,生产环境请换 Redis
// 结构: address => { nonce, message, expiresAt, used }
const nonceStore = new Map();
function generateNonce(length = 10) {
return crypto.randomBytes(length).toString('base64url').slice(0, length);
}
function buildMessage({ domain, address, uri, chainId, nonce, issuedAt, expirationTime }) {
return `${domain} wants you to sign in with your Ethereum account:
${address}
Sign in to MyApp.
URI: ${uri}
Version: 1
Chain ID: ${chainId}
Nonce: ${nonce}
Issued At: ${issuedAt}
Expiration Time: ${expirationTime}`;
}
app.post('/auth/nonce', (req, res) => {
const { address, chainId = 1 } = req.body;
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(); // 5 分钟有效期
const domain = 'localhost:5173';
const uri = 'http://localhost:5173';
const checksumAddress = ethers.getAddress(address);
const message = buildMessage({
domain,
address: checksumAddress,
uri,
chainId,
nonce,
issuedAt,
expirationTime,
});
nonceStore.set(checksumAddress.toLowerCase(), {
nonce,
message,
expiresAt: new Date(expirationTime).getTime(),
used: false,
});
res.json({
address: checksumAddress,
nonce,
message,
expirationTime,
});
});
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 params' });
}
const checksumAddress = ethers.getAddress(address);
const record = nonceStore.get(checksumAddress.toLowerCase());
if (!record) {
return res.status(400).json({ error: 'Nonce not found' });
}
if (record.used) {
return res.status(400).json({ error: 'Nonce already used' });
}
if (Date.now() > record.expiresAt) {
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 (recoveredAddress.toLowerCase() !== checksumAddress.toLowerCase()) {
return res.status(401).json({ error: 'Invalid signature' });
}
record.used = true;
nonceStore.set(checksumAddress.toLowerCase(), record);
const token = jwt.sign(
{
sub: checksumAddress,
walletAddress: checksumAddress,
},
JWT_SECRET,
{ expiresIn: '2h' }
);
res.json({
success: true,
token,
address: checksumAddress,
});
} 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: 'No token' });
}
try {
const payload = jwt.verify(token, JWT_SECRET);
res.json({
address: payload.walletAddress,
message: 'Authenticated request success',
});
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
});
app.listen(PORT, () => {
console.log(`Auth server running at http://localhost:${PORT}`);
});
3. 启动后端
node index.js
前端实现
1. 安装依赖
mkdir client && cd client
npm create vite@latest . -- --template react
npm install
npm install ethers
2. 编写页面
// client/src/App.jsx
import { useState } from 'react';
import { ethers } from 'ethers';
function App() {
const [address, setAddress] = useState('');
const [token, setToken] = useState('');
const [result, setResult] = useState('');
async function connectAndLogin() {
try {
if (!window.ethereum) {
alert('请先安装 MetaMask');
return;
}
const provider = new ethers.BrowserProvider(window.ethereum);
const accounts = await provider.send('eth_requestAccounts', []);
const signer = await provider.getSigner();
const userAddress = ethers.getAddress(accounts[0]);
const network = await provider.getNetwork();
const chainId = Number(network.chainId);
setAddress(userAddress);
const nonceResp = await fetch('http://localhost:3001/auth/nonce', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: userAddress, chainId }),
});
const nonceData = await nonceResp.json();
if (!nonceResp.ok) {
throw new Error(nonceData.error || '获取 nonce 失败');
}
const signature = await signer.signMessage(nonceData.message);
const verifyResp = await fetch('http://localhost:3001/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address: userAddress,
message: nonceData.message,
signature,
}),
});
const verifyData = await verifyResp.json();
if (!verifyResp.ok) {
throw new Error(verifyData.error || '验签失败');
}
setToken(verifyData.token);
setResult(`登录成功:${verifyData.address}`);
} catch (err) {
console.error(err);
setResult(`登录失败:${err.message}`);
}
}
async function fetchProfile() {
try {
const resp = await fetch('http://localhost:3001/me', {
headers: {
Authorization: `Bearer ${token}`,
},
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || '请求失败');
}
setResult(JSON.stringify(data, null, 2));
} catch (err) {
setResult(`获取用户信息失败:${err.message}`);
}
}
return (
<div style={{ padding: 24, fontFamily: 'sans-serif' }}>
<h1>Web3 钱包登录 Demo</h1>
<p>当前地址:{address || '未连接'}</p>
<button onClick={connectAndLogin}>连接钱包并登录</button>
<button onClick={fetchProfile} disabled={!token} style={{ marginLeft: 12 }}>
获取当前用户信息
</button>
<pre style={{ marginTop: 20, background: '#f5f5f5', padding: 16 }}>
{result}
</pre>
</div>
);
}
export default App;
3. 启动前端
npm run dev
到这里,一个基本的钱包登录 + 后端验签 + JWT 会话系统就跑起来了。
登录状态机设计
实际项目里,我建议明确区分认证状态,而不是把所有逻辑堆进一个 login() 函数里。这样排错非常省心。
stateDiagram-v2
[*] --> Disconnected
Disconnected --> WalletConnected: connect wallet
WalletConnected --> NonceIssued: request nonce
NonceIssued --> Signing: user signs message
Signing --> Verifying: submit signature
Verifying --> Authenticated: verify success
Verifying --> Failed: verify failed
Failed --> WalletConnected: retry
Authenticated --> [*]
核心组件职责划分
如果你准备把这套系统做进正式项目,建议按职责拆分:
classDiagram
class WalletClient {
+connect()
+signMessage(message)
+getAddress()
}
class AuthController {
+issueNonce(address, chainId)
+verifySignature(address, message, signature)
}
class NonceStore {
+save(address, nonceRecord)
+get(address)
+markUsed(address)
}
class SessionService {
+signToken(address)
+verifyToken(token)
}
WalletClient --> AuthController
AuthController --> NonceStore
AuthController --> SessionService
这样做的好处是:
- 钱包逻辑和认证逻辑解耦
- nonce 存储可从内存换 Redis
- 会话层可从 JWT 换成服务端 Session
- 便于未来支持多链或多钱包
常见坑与排查
这部分很重要。我自己做这类系统时,真正耗时间的往往不是主流程,而是这些边边角角。
1. 前后端消息字符串不完全一致
现象:
- 用户签名成功
- 后端
verifyMessage后恢复出的地址不对,或者总是验签失败
原因:
消息验签是对“原始字符串”做的,只要有一点差异都不行,比如:
- 少一个换行
- 多一个空格
- 时间字段格式不同
- 前端自己拼 message,后端也自己拼 message,但模板不一致
建议:
- 由后端统一生成 message,前端只负责签名
- 验签时使用后端原始 message,不要二次拼接
2. 地址大小写导致比较失败
现象:
- 恢复地址看起来是同一个地址,但比较失败
原因:
EVM 地址可能存在 checksum 大小写形式。
建议:
统一使用:
ethers.getAddress(address)
比较时使用:
a.toLowerCase() === b.toLowerCase()
3. nonce 没有失效或没标记已使用
风险:
同一签名可以重复登录,形成重放攻击。
建议:
- nonce 设置有效期,常见 5 分钟
- 验签成功后立即标记
used=true - 最好使用原子操作,避免并发下被重复消费
4. chainId 不匹配
现象:
用户连的是测试网,但系统以主网身份处理。
建议:
- 挑战消息中包含
chainId - 后端验证时检查当前业务允许的链
- 对多链系统,地址身份最好带上链上下文
5. CORS 或本地端口不一致
开发环境特别常见。
建议:
- 前后端明确配置允许来源
domain与uri要与实际环境对应- 本地开发和线上环境不要混用消息模板
6. MetaMask 弹窗不出现
可能原因:
- 浏览器拦截
- 前端没有在用户点击事件中直接触发签名
- 多次调用 provider / signer 顺序有问题
建议:
把 connect -> request nonce -> sign 尽量放在同一个用户交互流程里,不要被异步链路打断太多。
7. 合约钱包不能用 verifyMessage 直接处理
这是进阶坑。
对于 EOA(外部账户),verifyMessage 足够;但合约钱包可能需要用 ERC-1271 验证签名。
如果你的用户里有 Safe 钱包,这点一定要提前规划。
安全最佳实践
1. 使用标准化签名协议
如果条件允许,直接对齐 SIWE(EIP-4361)。
好处是:
- 字段规范统一
- 钱包生态更兼容
- 安全语义更明确
本文是手写版,适合理解原理;生产中建议尽量靠标准。
2. nonce 存 Redis,且单次消费
生产环境千万别用内存 Map。正确姿势通常是:
SETEX nonce:{address}存挑战数据- 验证成功后删除或标记已消费
- 借助 Redis 原子操作避免并发重放
3. 对域名、URI、链 ID 做强校验
不要只验证签名,还要验证上下文是否属于你的站点:
- 是否当前业务域名
- 是否允许的链
- 是否在有效时间内
- 是否来自正确环境(prod / staging)
4. token 最小化设计
JWT 里只放必要信息:
subwalletAddressrole(如有)exp
不要把过多链上画像、权限快照塞进 token,后续权限变更会很难处理。
5. 为敏感操作增加二次确认
钱包签名登录只证明“地址归属”,不代表所有高风险操作都应该直接放行。
例如:
- 提现
- 修改安全设置
- 绑定 Web2 账号
- 管理员操作
建议加:
- 二次签名
- 短时重新认证
- 设备风险校验
6. 防止签名钓鱼
前端展示的签名消息一定要可读,避免用户看到一串不可理解的 hex。
签名文案应该明确:
- 登录哪个站点
- 什么目的
- 是否会发起链上交易(登录签名通常不会)
性能最佳实践
钱包登录系统流量大时,性能瓶颈通常不在验签本身,而在状态管理和认证边界。
1. nonce 接口要轻量
/auth/nonce 是高频接口,建议:
- 不查复杂数据库
- 只做基础地址校验
- nonce 存 Redis,TTL 自动回收
2. 验签成功后减少链上依赖
本文方案中,登录过程不需要直接访问链节点,因为 EOA 验签是纯离线计算。
这点很重要:意味着登录延迟主要取决于钱包弹窗和网络请求,而不是 RPC 节点性能。
3. 头像、ENS、链上画像异步获取
很多项目喜欢在登录成功后立刻查:
- ENS 名称
- NFT 持仓
- Token 余额
- DAO 角色
我建议异步做,不要阻塞登录主链路。
登录认证和用户画像是两件事,架构上应拆开。
4. 会话层与权限层分离
- 会话层:用户是不是本人
- 权限层:这个地址能做什么
这样权限变化时,只更新权限缓存或数据库,不必强制所有用户重新登录。
容量估算与扩展思路
对于一个中型 DApp 后台,如果你有:
- 日活 1 万
- 峰值每秒 50 次登录
- nonce 有效期 5 分钟
那么 Redis 中同时存在的 nonce 数量通常并不夸张。粗略估算:
- 峰值 50 次/秒
- 5 分钟 = 300 秒
- 并发 nonce 量约 15000 条
每条记录只保存:
- address
- nonce
- message 或消息摘要
- expiresAt
- used 标记
这对 Redis 来说压力不大。
扩展方向
1. 支持多链
你可以把身份键从:
address
扩展为:
chainId:address
避免不同链混淆。
2. 支持合约钱包
增加 ERC-1271 检测流程:
- 先判断地址是否合约
- 若是 EOA,走
verifyMessage - 若是合约钱包,调用合约
isValidSignature
3. 支持角色系统
钱包地址只是身份入口,真正业务还需要:
- 白名单
- 会员等级
- 管理员角色
- 链上持仓门槛
建议在认证通过后做一层“地址到用户实体”的绑定。
一套更稳的落地建议
如果你准备把这套方案上生产,我建议按下面顺序做:
- 先跑通 EOA 钱包登录最小链路
- 接入 Redis 管理 nonce
- 对齐 SIWE 消息格式
- 加入 JWT 刷新与登出机制
- 补上角色与权限模型
- 如果有企业级需求,再支持 ERC-1271 和多链
不要一开始就试图做成“超级通用身份中台”。
Web3 认证系统很容易陷入过度设计,尤其是在多链、多钱包、多画像同时推进时。
总结
这类去中心化身份认证系统,真正的关键点可以浓缩成一句话:
用一次性挑战消息证明钱包控制权,再用传统会话机制承接后续请求。
本文我们完成了这些核心环节:
- 理解为什么“连接钱包”不等于“认证”
- 设计了带 nonce 的挑战消息机制
- 使用
ethers.verifyMessage实现后端验签 - 通过 JWT 建立业务登录态
- 分析了常见坑、安全边界和性能取舍
如果你现在正在做一个中级 Web3 应用,我的可执行建议是:
- 认证主链路先简单、先标准
- 消息模板由后端统一生成
- nonce 必须一次性且有过期时间
- JWT 只承载会话,不要承载复杂业务状态
- 登录与链上画像解耦
- 有合约钱包用户时,尽早评估 ERC-1271
最后说个很实际的经验:
Web3 登录系统最怕的不是“代码写不出来”,而是“看起来能用,但安全边界是虚的”。你只要把 挑战消息、验签、nonce、会话 这四件事做扎实,这套身份系统就已经具备了不错的工程基础。