Web3 中级实战:从零搭建基于 EVM 的钱包登录与链上签名认证系统
很多人第一次做 Web3 登录,思路都很直接:
“用户连上钱包,拿到地址,不就等于登录了吗?”
我一开始也这么想,后来很快发现这只是拿到了一个公开地址,并没有完成“认证”。真正的认证问题是:
- 这个地址是不是当前用户真实控制的?
- 这个请求是不是刚刚发起的,而不是别人重放的?
- 登录后服务端怎么建立会话?
- 切链、换钱包、签名格式不一致时,系统怎么稳住?
这篇文章我们不讲概念空转,而是从架构视角 + 可运行代码,带你搭一个完整的、基于 EVM 的钱包登录与链上签名认证系统。
目标读者是已经接触过前端、Node.js、以太坊基础交互的中级开发者。
背景与问题
在传统 Web 系统里,登录通常依赖:
- 用户名 + 密码
- 手机验证码
- OAuth(微信、GitHub、Google)
而在 Web3 里,最常见的是:
- 用户连接钱包
- 服务端发一个随机挑战(nonce)
- 用户使用钱包签名
- 服务端验签,通过后发放 session/JWT
看起来不复杂,但真正落地时会碰到几个核心问题。
1. 连接钱包不等于登录
eth_requestAccounts 只是让用户暴露地址。
地址公开可见,任何人都能知道一个地址存在,所以这一步不具备认证能力。
真正的认证依赖的是:只有私钥控制者才能生成正确签名。
2. 只验签还不够
如果服务端直接让用户签一个固定字符串,比如:
Login to my app
那么攻击者一旦拿到这份签名,就可能无限复用。
所以必须加入:
- nonce
- 域名
- 时间戳
- 过期时间
- chainId
- 资源范围(可选)
这也是为什么现在越来越多项目采用 SIWE(Sign-In with Ethereum) 思路。
3. “链上签名认证”并不一定真的上链
这是一个容易误解的点。
大多数登录认证中的签名,本质上是链下签名(off-chain signature),但签名体系来自 EVM 账户模型,服务端用 ECDSA 恢复地址并验证,所以通常也被叫作链上签名认证。
它的好处是:
- 不消耗 gas
- 用户体验更好
- 仍然能证明地址控制权
4. 架构上要把“认证”和“授权”分开
建议把系统拆成两层:
- 认证层:用户是否真正控制某个钱包地址
- 授权层:这个地址在你的业务系统里拥有什么权限
例如:
- 认证:这个人控制
0xabc... - 授权:这个地址是否是管理员、是否领取过资格、是否绑定了某个 UID
这两层不要混在一起,否则后期扩展会很痛苦。
方案概览
我们先给出一个可落地的整体方案:
- 前端请求服务端生成登录挑战消息
- 服务端返回包含 nonce 的标准消息
- 用户用钱包签名
- 前端把
message + signature + address提交给服务端 - 服务端验签、校验 nonce/时效/domain/chainId
- 验证通过后创建用户会话,返回 JWT 或 session cookie
整体架构图
flowchart TD
A[用户打开前端 DApp] --> B[连接 EVM 钱包]
B --> C[前端请求后端获取 nonce]
C --> D[后端生成挑战消息]
D --> E[前端调用 personal_sign]
E --> F[用户在钱包确认签名]
F --> G[前端提交 message + signature]
G --> H[后端验签恢复地址]
H --> I{校验 nonce/域名/时间/chainId}
I -- 通过 --> J[签发 JWT/Session]
I -- 失败 --> K[拒绝登录]
核心原理
这一部分是整套系统的“地基”。
1. EVM 签名验证原理
EVM 钱包一般使用 secp256k1 椭圆曲线算法。
当用户对消息签名后,服务端可以通过签名恢复出公钥,再推导出地址。
验证逻辑大致是:
- 用户签名消息
message - 得到
signature - 服务端执行
recoverAddress(message, signature) - 若恢复出的地址与用户声称地址一致,则说明签名有效
在以太坊生态中,最常见的是:
personal_signeth_signTypedData_v4(EIP-712)
个人建议
如果你是第一次搭系统,登录认证先用 personal_sign。
原因很现实:
- 钱包兼容性更稳
- 实现更简单
- 排查成本更低
如果你需要更强结构化表达,再升级到 EIP-712。
2. 为什么需要 nonce
nonce 的目的不是“随机好看”,而是抗重放攻击。
如果没有 nonce:
- 攻击者截获一次合法签名
- 以后就可以不断拿这个签名来冒充用户登录
有了 nonce 后:
- 每次登录的消息都不同
- 服务端验签后立即把 nonce 标记为已使用或直接销毁
- 同一份签名无法再次通过
3. 推荐消息结构
虽然可以手写任意字符串,但建议消息里至少包含:
- domain:当前站点域名
- address:用户地址
- statement:登录说明
- uri:站点 URI
- version:协议版本
- chainId:链 ID
- nonce:随机数
- issuedAt:签发时间
- expirationTime:过期时间
一个典型示例如下:
example.com wants you to sign in with your Ethereum account:
0xAbC123...
Sign in to Example App.
URI: https://example.com
Version: 1
Chain ID: 1
Nonce: a8f9c2d1
Issued At: 2024-01-01T10:00:00.000Z
Expiration Time: 2024-01-01T10:05:00.000Z
4. 登录序列图
sequenceDiagram
participant U as 用户
participant W as 钱包
participant F as 前端
participant B as 后端
U->>F: 点击登录
F->>W: 请求连接钱包
W-->>F: 返回 address
F->>B: GET /auth/nonce?address=...
B-->>F: 返回 nonce + message
F->>W: personal_sign(message)
U->>W: 确认签名
W-->>F: 返回 signature
F->>B: POST /auth/verify
B->>B: 恢复地址并校验 nonce/时效
B-->>F: JWT / Set-Cookie
5. 方案对比与取舍
方案 A:只连接钱包,不做签名
优点
- 实现最简单
- 几乎零后端逻辑
缺点
- 没有认证能力
- 不能证明地址控制权
- 不能安全建立会话
结论
只能做“展示地址”,不能叫登录。
方案 B:personal_sign 登录
优点
- 实现简单
- 钱包兼容性好
- 对大多数 DApp 足够用
缺点
- 消息结构化程度一般
- 用户在钱包里看到的是原始文本
结论
非常适合作为第一版生产方案。
方案 C:EIP-712 Typed Data 登录
优点
- 结构化强
- 字段更清晰
- 更适合复杂授权场景
缺点
- 前后端实现更复杂
- 某些钱包或移动端兼容性需要额外测试
结论
适合有协议化需求、审计要求更高的系统。
实战代码(可运行)
下面我们用一个最小可运行方案:
- 前端:原生 HTML + ethers.js
- 后端:Node.js + Express + ethers + jsonwebtoken
- 认证方式:
personal_sign - 会话方式:JWT
为了让示例能直接跑,我这里用内存存 nonce。
真正生产环境请替换成 Redis 或数据库。
项目结构
web3-wallet-auth/
├─ server/
│ ├─ package.json
│ └─ index.js
└─ client/
└─ index.html
后端实现
1. 安装依赖
mkdir -p web3-wallet-auth/server
cd web3-wallet-auth/server
npm init -y
npm install express cors ethers jsonwebtoken
2. server/index.js
const express = require("express");
const cors = require("cors");
const { ethers } = require("ethers");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const app = express();
app.use(cors());
app.use(express.json());
const PORT = 3001;
const JWT_SECRET = "replace_this_in_production";
// 用内存存储 nonce,生产环境换成 Redis/DB
// key: address.toLowerCase()
const nonceStore = new Map();
function generateNonce() {
return crypto.randomBytes(16).toString("hex");
}
function buildMessage({ domain, address, uri, chainId, nonce, issuedAt, expirationTime }) {
return `${domain} wants you to sign in with your Ethereum account:
${address}
Sign in to Example App.
URI: ${uri}
Version: 1
Chain ID: ${chainId}
Nonce: ${nonce}
Issued At: ${issuedAt}
Expiration Time: ${expirationTime}`;
}
// 1) 获取登录挑战消息
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 record = {
nonce,
chainId: Number(chainId),
issuedAt,
expirationTime,
domain: "localhost:5500",
uri: "http://localhost:5500"
};
nonceStore.set(address.toLowerCase(), record);
const message = buildMessage({
domain: record.domain,
address,
uri: record.uri,
chainId: record.chainId,
nonce,
issuedAt,
expirationTime
});
res.json({
address,
message,
nonce,
issuedAt,
expirationTime
});
});
// 2) 验签登录
app.post("/auth/verify", (req, res) => {
try {
const { address, message, signature } = req.body;
if (!address || !message || !signature) {
return res.status(400).json({ error: "Missing fields" });
}
const record = nonceStore.get(address.toLowerCase());
if (!record) {
return res.status(400).json({ error: "Nonce not found" });
}
if (new Date(record.expirationTime).getTime() < Date.now()) {
nonceStore.delete(address.toLowerCase());
return res.status(400).json({ error: "Nonce expired" });
}
// 验签
const recovered = ethers.verifyMessage(message, signature);
if (recovered.toLowerCase() !== address.toLowerCase()) {
return res.status(401).json({ error: "Signature mismatch" });
}
// 核验 message 是否包含当前服务端签发的 nonce,避免伪造 message
if (!message.includes(`Nonce: ${record.nonce}`)) {
return res.status(401).json({ error: "Invalid nonce in message" });
}
if (!message.includes(`Chain ID: ${record.chainId}`)) {
return res.status(401).json({ error: "Invalid chainId in message" });
}
if (!message.includes(`URI: ${record.uri}`)) {
return res.status(401).json({ error: "Invalid uri in message" });
}
// 一次性使用
nonceStore.delete(address.toLowerCase());
const token = jwt.sign(
{
sub: address.toLowerCase(),
wallet: address.toLowerCase(),
loginType: "evm-wallet"
},
JWT_SECRET,
{ expiresIn: "2h" }
);
return res.json({
ok: true,
token,
address: address.toLowerCase()
});
} catch (err) {
console.error(err);
return res.status(500).json({ error: "Verification failed" });
}
});
// 3) 受保护接口示例
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({
wallet: payload.wallet,
loginType: payload.loginType
});
} catch (err) {
res.status(401).json({ error: "Invalid token" });
}
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
3. 启动后端
node index.js
前端实现
为了演示最小可运行逻辑,我这里不引入 React,直接用一个静态页面。
client/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>Wallet Login Demo</title>
</head>
<body>
<h2>EVM 钱包登录演示</h2>
<button id="connectBtn">连接钱包并登录</button>
<button id="meBtn">获取当前用户信息</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 connectAndLogin() {
if (!window.ethereum) {
alert("请先安装 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);
log({ step: "wallet connected", address, chainId });
const nonceResp = await fetch(
`http://localhost:3001/auth/nonce?address=${address}&chainId=${chainId}`
);
const nonceData = await nonceResp.json();
if (!nonceResp.ok) {
throw new Error(nonceData.error || "Failed to get nonce");
}
const message = nonceData.message;
// 使用 signer.signMessage,本质对应 personal_sign 流程
const signature = await signer.signMessage(message);
const verifyResp = await fetch("http://localhost:3001/auth/verify", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
address,
message,
signature
})
});
const verifyData = await verifyResp.json();
if (!verifyResp.ok) {
throw new Error(verifyData.error || "Verify failed");
}
localStorage.setItem("token", verifyData.token);
log({
step: "login success",
data: verifyData
});
} catch (err) {
console.error(err);
log(`登录失败: ${err.message}`);
}
}
async function getMe() {
const token = localStorage.getItem("token");
if (!token) {
log("请先登录");
return;
}
const resp = await fetch("http://localhost:3001/me", {
headers: {
Authorization: `Bearer ${token}`
}
});
const data = await resp.json();
log(data);
}
document.getElementById("connectBtn").addEventListener("click", connectAndLogin);
document.getElementById("meBtn").addEventListener("click", getMe);
</script>
</body>
</html>
运行前端
你可以直接用任意静态服务启动,例如:
cd ../client
python3 -m http.server 5500
然后访问:
http://localhost:5500
一次完整验证流程
你可以按下面顺序手动测试:
- 打开前端页面
- 点击“连接钱包并登录”
- 钱包授权地址访问
- 钱包弹出签名确认
- 前端把签名提交给后端
- 后端验签成功,返回 JWT
- 点击“获取当前用户信息”
- 能拿到
/me返回结果,说明登录闭环成立
认证状态图
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Connected: 连接钱包
Connected --> ChallengeIssued: 请求 nonce
ChallengeIssued --> Signed: 用户完成签名
Signed --> Verified: 后端验签成功
Signed --> Rejected: 验签失败/过期/重放
Verified --> SessionActive: 签发 JWT/Session
SessionActive --> Disconnected: 退出登录/过期
从架构角度看:这套系统为什么成立
这里我们稍微上升一个层次,不只是“能跑”,而是理解它为什么是合理的。
1. 钱包负责身份控制
私钥只在钱包端保管,服务端永远不触碰私钥。
这意味着:
- 降低平台保管凭证的风险
- 认证权交给用户控制的钱包
- 平台只负责验证,不负责托管
2. 后端负责挑战和会话
服务端生成 nonce 和消息,是为了:
- 把登录请求和当前站点绑定
- 限定请求时效
- 防止签名被复用
- 把一次性签名转换成后续可用的会话 token
这其实和传统 Web 的“验证码换 session”很像,只是验证码换成了钱包签名。
3. JWT 只是会话载体,不是认证本体
很多同学容易混淆:
- 签名认证:证明你拥有地址
- JWT:证明你已经通过本系统登录
JWT 不是为了替代签名,而是为了避免用户每次请求都签名。
否则每调一个接口都弹钱包,用户会疯掉。
常见坑与排查
这部分我建议你认真看。
因为真实项目里,80% 的时间都花在这些“明明看起来没问题”的细节上。
1. 前端签了,后端却验不过
常见原因
- 前后端使用的 message 字符串不完全一致
- 字符串中的换行、空格、缩进不同
- 地址大小写混用
- 前端签的是 UTF-8 文本,后端按别的格式处理
排查方法
第一步,不要猜,直接打印:
console.log("message from frontend:", JSON.stringify(message));
console.log("signature:", signature);
后端也打印:
console.log("message on server:", JSON.stringify(message));
重点看:
- 是否有
\n - 是否多了空格
- 是否字段顺序不同
我当时踩过一个很典型的坑:前端拼接文案时多了一行空行,结果验签一直失败,看了半天代码才发现。
2. recovered address 和用户地址不一致
常见原因
- 用户切换了钱包账号
- 前端拿到地址后,签名前地址变了
- 使用了错误签名方法
- 你验的是
hash,但签的是原始 message
建议
签名前重新取一次 signer 地址:
const signer = await provider.getSigner();
const address = await signer.getAddress();
然后服务端统一:
recovered.toLowerCase() === address.toLowerCase()
3. nonce 明明生成了,却总提示无效
常见原因
- nonce 被覆盖了
- 同一地址并发登录,旧 nonce 失效
- 后端重启后内存数据丢失
- 前端拿了旧 message 去签
解决思路
生产环境使用 Redis,结构例如:
- key:
login_nonce:${address} - value:
{ nonce, issuedAt, expirationTime, chainId, domain } - TTL: 5 分钟
并且验签成功后立即删除。
4. 本地能跑,上线后签名总失败
常见原因
- 线上 domain 和本地写死的不一致
- 反向代理后
Host、Origin不一致 - HTTPS 域名变了,消息中的 URI 还写着 localhost
建议
消息中的:
- domain
- uri
都应该由后端根据实际环境动态生成,而不是硬编码在前端。
5. 用户签名后拒绝登录,提示链 ID 不匹配
场景
你要求用户在主网登录,但他当前连的是 Sepolia 或 BSC。
处理建议
在前端签名前先检查链:
const network = await provider.getNetwork();
if (Number(network.chainId) !== 1) {
alert("请切换到 Ethereum Mainnet");
return;
}
如果你支持多链,也要在后端明确允许列表。
安全最佳实践
这一部分决定了你的系统是“Demo 能跑”,还是“上线能扛”。
1. nonce 必须一次性、短时有效
最低要求:
- 长度足够随机
- TTL 3~10 分钟
- 使用后立刻销毁
不要复用 nonce,也不要把旧 nonce 当缓存继续接受。
2. 验签后必须比对服务端原始挑战内容
很多实现只做:
ethers.verifyMessage(message, signature)
这还不够。
因为攻击者完全可以对另一个 message 做合法签名。
服务端必须确认这份 message 就是自己发出的挑战,至少校验:
- nonce
- chainId
- domain
- uri
- issuedAt / expirationTime
更稳妥的做法是:服务端存完整 challenge,验签时逐字段比对。
3. 生产环境优先用 HttpOnly Cookie 承载会话
本文演示用 JWT + localStorage,是为了简单。
但上线时我更建议:
- token 放在
HttpOnly + Secure + SameSiteCookie - 减少 XSS 窃取 token 风险
如果你必须用 JWT,也要控制:
- 过期时间
- 刷新机制
- 撤销策略
4. 限流与风控不能省
钱包登录并不意味着不需要风控。
后端至少要对这些接口做限流:
/auth/nonce/auth/verify
否则会被刷:
- 挑战生成接口
- 验签接口
- 绑定/注册接口
可以按以下维度限流:
- IP
- address
- User-Agent
- 设备指纹(可选)
5. 绑定业务账户时要做好幂等
真实项目里,登录成功后一般还会做:
- 自动注册用户
- 绑定邀请关系
- 初始化画像
- 同步链上资产快照
这些动作必须幂等。
因为用户可能重复登录、重复点击、网络重试。
6. 不要默认 EOA 是唯一身份形态
这一点是中级项目常被忽略的。
随着账户抽象和智能合约钱包普及,未来你会遇到:
- Safe
- Argent
- AA 钱包
- 合约账户签名校验
EOA 可以用 verifyMessage 恢复地址,但合约钱包往往需要走 EIP-1271 验证。
如果你的产品面向更广用户,后续应扩展为:
- EOA:ECDSA 恢复地址
- Contract Wallet:调用
isValidSignature
性能与容量估算
认证接口虽然单次逻辑不重,但登录高峰时也有几个注意点。
1. 验签开销并不大,瓶颈常在存储和限流
对大多数 Node 服务来说:
- 单次
verifyMessage开销可接受 - 更常见瓶颈在 Redis、数据库、日志、网关限流
所以优化优先级建议是:
- nonce 存储与删除
- 接口限流
- JWT 签发
- 日志采样
2. nonce 存储建议放 Redis
原因很直接:
- 天然支持 TTL
- 多实例共享
- 适合短生命周期挑战数据
一个 nonce 记录即使按 300~500 字节估算,
10 万并发待验证登录请求,也不过几十 MB 量级,完全可控。
3. JWT 不要塞太多业务字段
JWT 的 payload 建议只放:
subwalletiatexp- 少量角色信息
不要把用户画像、资产列表、权限全集都塞进去。
否则:
- token 过大
- 网关传输浪费
- 权限变更难即时生效
进阶扩展:升级到 EIP-712 的思路
如果你的系统后续要支持更结构化的签名消息,可以升级到 EIP-712。
典型优点:
- 字段明确
- 更适合机器解析
- 审计更友好
但你要多处理:
- domain separator
- typed data schema
- 钱包兼容差异
- 前后端字段序列化一致性
如果是团队第一版,我一般建议:
- 先用
personal_sign把认证链路跑通 - 稳定后再按场景升级 EIP-712
- 若要支持合约钱包,再加 EIP-1271
这个演进路径更稳,不容易一上来把复杂度拉满。
一个更稳的生产版落地建议
如果你准备真正上线,我建议至少做到下面这些:
基础版
personal_sign- Redis nonce
- JWT 或 Cookie 会话
- 限流
- 登录审计日志
进阶版
- SIWE 标准消息
- 多链支持
- 钱包切链引导
- 自动注册/绑定流程幂等化
- 风控评分
企业版/高安全版
- EIP-712
- EIP-1271 合约钱包支持
- 设备风控
- 会话撤销
- 登录行为告警
- 签名内容审计与灰度发布
总结
我们这篇文章搭建的,不只是“钱包弹一下签名”的 Demo,而是一套完整的 EVM 钱包登录认证架构:
- 前端连接钱包
- 后端生成 challenge
- 用户签名证明地址控制权
- 服务端验签并做 nonce/时效/domain/chainId 校验
- 最终签发业务会话
核心结论可以记住三点:
- 连接钱包不是登录,签名验证才是认证核心
- nonce + 过期时间 + 域名绑定,是防重放的基础
- 认证解决“你是谁”,授权解决“你能做什么”
如果你现在要开始做第一版,我的可执行建议是:
- 先上
personal_sign - nonce 放 Redis,5 分钟过期,一次性消费
- 服务端保存完整 challenge 并逐字段校验
- 会话优先用 HttpOnly Cookie
- 为未来预留 EIP-712 和 EIP-1271 扩展位
边界条件也要明确:
- 本文示例主要针对 EOA(普通 EVM 钱包)
- 如果要支持 智能合约钱包,验签逻辑需要补充 EIP-1271
- 如果你的系统是高价值资产场景,光有登录认证还不够,必须叠加风控和交易确认策略
你可以先把本文代码跑通,再逐步替换成 Redis、标准 SIWE、Cookie 会话和多链配置。
这样做的好处是:每一步都能验证、能回滚、能上线,而不是一开始就把系统做得又重又脆。