Web3 中级实战:用 EIP-712 与钱包签名实现链上身份认证与防重放登录系统
在 Web3 应用里,“连接钱包”很容易,“安全登录”其实没那么容易。
很多项目一开始会直接让用户签一段字符串,比如:
Sign this message to login
它能跑,但问题也很明显:
- 消息不可读,用户不知道自己到底签了什么
- 签名内容结构化程度低,前后端容易约定不一致
- 很容易遗漏 nonce、防重放、域隔离、过期时间 这些关键字段
- 一旦把“登录签名”和“别的业务签名”混在一起,风险会上升
如果你的系统已经从 Demo 走向真实用户,建议尽快从“随便签个字符串”升级到 EIP-712 结构化签名登录。
这篇文章,我会从架构视角把这套方案拆开:为什么要这样做、关键字段怎么设计、前后端如何配合、链上与链下边界怎么划分,以及一套可运行的 Node.js 示例代码。你可以把它当作一个中级项目的登录基线。
背景与问题
传统 Web2 登录和 Web3 登录的根本差异
Web2 登录通常依赖:
- 用户名 + 密码
- 短信验证码
- OAuth 三方登录
- 服务端会话或 JWT
而 Web3 登录更像:
- 用户持有私钥
- 钱包负责签名
- 服务端验证签名
- 验证通过后签发应用自己的 session/JWT
也就是说,钱包签名本质上是在证明“我控制这个地址”,而不是直接替代你业务系统里的会话层。
只做“签名登录”还不够
如果你只是让用户签名,然后服务端验一下地址是否对得上,常见问题马上就会出现:
-
重放攻击 攻击者拿到一份旧签名,在有效期内甚至长期重复提交。
-
跨域重放 某个站点的签名,被另一个站点拿去冒用。
-
跨链重放 没有 chainId 约束时,不同链环境可能出现误用。
-
签名语义不清 用户钱包里只看到一段乱七八糟的字符串,体验差,也容易误签。
-
Nonce 生命周期管理缺失 登录 nonce 生成了,但没有一次性消费,没有过期策略,也没有并发控制。
所以问题的关键不是“能不能验签”,而是:
如何设计一套既符合钱包生态、又能抵抗重放、还能稳定集成前后端的认证协议?
方案目标与架构边界
在这类系统里,我通常建议先明确 4 个目标:
- 身份证明:用户确实控制某个钱包地址
- 防重放:同一份签名不能重复利用
- 域隔离:签名只对当前应用有效
- 可审计:登录过程可追踪、可排查、可扩展
推荐架构
- 钱包:负责 EIP-712 签名
- 前端:请求 challenge、发起签名、提交签名结果
- 认证服务:生成 nonce、组织 typed data、验签、消费 nonce、签发 JWT/session
- 链上合约:不是登录必需,但可作为权限补充来源,比如是否持有 NFT、是否具备某角色
这里要强调一个边界:
登录认证本身通常是链下完成的,链上更多负责“权限证明素材”,而不是承担每次登录开销。
方案总览
整个登录流程建议拆成两个阶段:
-
Challenge 获取阶段 服务端生成一次性 nonce,并结合 domain、chainId、issuedAt、expiration 等信息返回给前端
-
签名验证阶段 前端用钱包对 EIP-712 typed data 签名,服务端验证签名、核对 nonce 未使用且未过期,然后签发会话令牌
flowchart TD
A[前端请求登录挑战 challenge] --> B[服务端生成 nonce]
B --> C[服务端返回 EIP-712 typed data]
C --> D[前端调用钱包 signTypedData]
D --> E[用户确认签名]
E --> F[前端提交 address + signature + challengeId]
F --> G[服务端恢复 signer 地址]
G --> H{nonce 未使用且未过期?}
H -- 否 --> I[拒绝登录]
H -- 是 --> J[标记 nonce 已消费]
J --> K[签发 JWT/Session]
核心原理
1. 为什么选 EIP-712
EIP-712 的价值不只是“结构化”,而是它天然适合做认证协议:
- 用户钱包能展示字段化内容
- 可以引入
domain.name、domain.version、chainId、verifyingContract - 消息类型明确,前后端不容易串格式
- 与
eth_sign、personal_sign相比,可读性和安全边界更清晰
一个典型的登录消息应该包含什么
我推荐至少包含这些字段:
wallet:用户地址nonce:一次性随机值issuedAt:签发时间expirationTime:过期时间chainId:链 IDstatement:给用户看的简短说明uri:当前应用域名或站点 URI
如果你要支持多租户、多环境或更严格隔离,还可以加入:
audiencerequestIdresourcessessionKeyHint
2. 防重放靠什么实现
很多人以为“有签名就安全”,其实不对。防重放的核心不是签名算法,而是协议状态管理。
完整防重放至少要做到:
- nonce 随机且唯一
- nonce 一次性消费
- 签名消息带过期时间
- 签名消息绑定 domain / chainId / uri
- 服务端验签后原子性更新 nonce 状态
也就是说,防重放真正落地的关键点在服务端数据库。
sequenceDiagram
participant U as 用户钱包
participant F as 前端
participant S as 认证服务
participant DB as Nonce存储
F->>S: GET /auth/challenge?address=0x...
S->>DB: 创建 nonce(status=pending, expireAt)
DB-->>S: 返回 challengeId + nonce
S-->>F: typedData
F->>U: signTypedData(typedData)
U-->>F: signature
F->>S: POST /auth/verify
S->>S: recoverAddress(signature, typedData)
S->>DB: 原子更新 nonce pending -> used
alt 成功
S-->>F: JWT / Session
else 已使用或过期
S-->>F: 401/409 拒绝
end
3. 域分离为什么重要
EIP-712 的 domain 可以理解为“这份签名的作用范围”。
推荐至少设置:
name: 应用名,比如MyDapp Authversion: 协议版本,比如1chainId: 当前链 IDverifyingContract: 如果是纯链下登录,也可以固定一个协议标识地址;如果你有实际认证相关合约,也可以绑定它
这里我踩过一个坑:有些团队做纯链下登录时,把 verifyingContract 留空,表面上没问题,但后续多环境、多协议版本并存时,签名边界会变模糊。
更稳妥的做法是:
- 要么明确约定一个固定占位地址并写入文档
- 要么至少保证
name + version + chainId + uri的组合足够唯一
方案对比与取舍分析
方案 A:personal_sign 登录
优点
- 集成最简单
- 老项目兼容性高
缺点
- 文本可读性依赖你自己拼接
- 结构容易不统一
- 钱包展示体验不稳定
- 字段约束弱,长期维护成本高
方案 B:EIP-712 登录
优点
- 结构化、可审计
- 域分离更清晰
- 更适合中长期演进
- 对防重放字段支持自然
缺点
- 前后端类型定义必须严格一致
- 某些钱包/移动端兼容性需要额外测试
方案 C:SIWE(Sign-In with Ethereum, EIP-4361)
优点
- 标准化程度高
- 多数钱包和生态工具支持较好
- 适合通用 Ethereum 登录
缺点
- 文本消息为主,不是纯 EIP-712
- 如果你有更复杂业务字段,扩展方式未必最顺手
我的建议
- 如果你要做 通用以太坊账号登录标准兼容:优先评估 SIWE
- 如果你要做 定制化业务认证协议、强域隔离、多字段结构化管理:优先 EIP-712
- 如果你只是做内部工具或 PoC:
personal_sign可用,但要尽快升级
数据模型设计
一个实用的 login_challenge 表,至少需要这些字段:
CREATE TABLE login_challenge (
id VARCHAR(64) PRIMARY KEY,
wallet_address VARCHAR(42) NOT NULL,
nonce VARCHAR(128) NOT NULL UNIQUE,
chain_id BIGINT NOT NULL,
domain_name VARCHAR(128) NOT NULL,
uri TEXT NOT NULL,
issued_at TIMESTAMP NOT NULL,
expiration_time TIMESTAMP NOT NULL,
status VARCHAR(16) NOT NULL,
signature TEXT,
used_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
状态建议
pending:已签发 challenge,待提交签名used:已成功消费expired:已过期revoked:主动撤销,比如风险控制命中
stateDiagram-v2
[*] --> pending
pending --> used: 验签成功并原子消费
pending --> expired: 超时
pending --> revoked: 风控撤销
used --> [*]
expired --> [*]
revoked --> [*]
实战代码(可运行)
下面给一套可运行的最小实现:
- 后端:Node.js + Express + ethers
- 前端:浏览器 + MetaMask / 兼容 EIP-712 钱包
- 存储:为了方便演示,先用内存 Map;生产环境请换成 Redis / PostgreSQL
后端:认证服务
先安装依赖:
npm init -y
npm install express ethers jsonwebtoken cors
server.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";
const APP_DOMAIN = "localhost";
const APP_URI = "http://localhost:5173";
const CHAIN_ID = 1;
// 演示用内存存储
// 生产环境请使用 Redis / PostgreSQL,并使用事务或原子 CAS 更新
const challenges = new Map();
function randomNonce(size = 16) {
return crypto.randomBytes(size).toString("hex");
}
function buildTypedData({ address, nonce, issuedAt, expirationTime }) {
return {
domain: {
name: "MyDapp Auth",
version: "1",
chainId: CHAIN_ID,
verifyingContract: "0x0000000000000000000000000000000000000001",
},
types: {
Login: [
{ name: "wallet", type: "address" },
{ name: "statement", type: "string" },
{ name: "nonce", type: "string" },
{ name: "uri", type: "string" },
{ name: "issuedAt", type: "string" },
{ name: "expirationTime", type: "string" },
],
},
primaryType: "Login",
message: {
wallet: address,
statement: "Sign this message to authenticate with MyDapp.",
nonce,
uri: APP_URI,
issuedAt,
expirationTime,
},
};
}
app.get("/auth/challenge", (req, res) => {
const address = req.query.address;
if (!address || !ethers.isAddress(address)) {
return res.status(400).json({ error: "invalid address" });
}
const id = crypto.randomUUID();
const nonce = randomNonce(16);
const issuedAt = new Date().toISOString();
const expirationTime = new Date(Date.now() + 5 * 60 * 1000).toISOString();
const typedData = buildTypedData({
address,
nonce,
issuedAt,
expirationTime,
});
challenges.set(id, {
id,
address: ethers.getAddress(address),
nonce,
issuedAt,
expirationTime,
status: "pending",
typedData,
createdAt: Date.now(),
});
res.json({
challengeId: id,
typedData,
});
});
app.post("/auth/verify", async (req, res) => {
try {
const { challengeId, signature } = req.body;
if (!challengeId || !signature) {
return res.status(400).json({ error: "missing challengeId or signature" });
}
const record = challenges.get(challengeId);
if (!record) {
return res.status(404).json({ error: "challenge not found" });
}
if (record.status !== "pending") {
return res.status(409).json({ error: "challenge already used or invalid" });
}
if (new Date(record.expirationTime).getTime() < Date.now()) {
record.status = "expired";
return res.status(401).json({ error: "challenge expired" });
}
const recovered = ethers.verifyTypedData(
record.typedData.domain,
record.typedData.types,
record.typedData.message,
signature
);
const normalizedRecovered = ethers.getAddress(recovered);
if (normalizedRecovered !== record.address) {
return res.status(401).json({ error: "signature verification failed" });
}
// 演示里直接更新;生产环境必须原子消费,避免并发重放
record.status = "used";
record.signature = signature;
record.usedAt = Date.now();
const token = jwt.sign(
{
sub: record.address,
wallet: record.address,
authMethod: "wallet_eip712",
},
JWT_SECRET,
{ expiresIn: "2h", issuer: APP_DOMAIN, audience: APP_URI }
);
res.json({
ok: true,
address: record.address,
token,
});
} catch (err) {
console.error(err);
res.status(500).json({ error: "internal error" });
}
});
app.get("/me", (req, res) => {
const auth = req.headers.authorization || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
if (!token) {
return res.status(401).json({ error: "missing token" });
}
try {
const payload = jwt.verify(token, JWT_SECRET, {
issuer: APP_DOMAIN,
audience: APP_URI,
});
res.json({ user: payload });
} catch (err) {
res.status(401).json({ error: "invalid token" });
}
});
app.listen(PORT, () => {
console.log(`Auth server listening on http://localhost:${PORT}`);
});
前端:发起签名并登录
下面是一段最小化浏览器脚本。你也可以很容易改成 React + wagmi + viem 版本。
index.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>EIP-712 Login Demo</title>
</head>
<body>
<button id="connectBtn">连接钱包并登录</button>
<pre id="output"></pre>
<script>
const output = document.getElementById("output");
const log = (msg) => {
output.textContent += `${typeof msg === "string" ? msg : JSON.stringify(msg, null, 2)}\n`;
};
async function main() {
if (!window.ethereum) {
alert("请安装 MetaMask");
return;
}
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
const address = accounts[0];
log("当前地址: " + address);
const challengeResp = await fetch(`http://localhost:3001/auth/challenge?address=${address}`);
const challenge = await challengeResp.json();
if (!challenge.challengeId) {
log(challenge);
return;
}
const typedData = challenge.typedData;
// 注意:eth_signTypedData_v4 需要第二个参数是字符串化 JSON
const signature = await window.ethereum.request({
method: "eth_signTypedData_v4",
params: [address, JSON.stringify(typedData)],
});
log("签名成功: " + signature);
const verifyResp = await fetch("http://localhost:3001/auth/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
challengeId: challenge.challengeId,
signature,
}),
});
const verifyResult = await verifyResp.json();
log(verifyResult);
if (verifyResult.token) {
localStorage.setItem("token", verifyResult.token);
const meResp = await fetch("http://localhost:3001/me", {
headers: {
Authorization: `Bearer ${verifyResult.token}`,
},
});
const me = await meResp.json();
log(me);
}
}
document.getElementById("connectBtn").onclick = () => {
main().catch((err) => {
console.error(err);
log("出错: " + err.message);
});
};
</script>
</body>
</html>
如何运行
先启动后端:
node server.js
然后用任意静态服务器启动前端,比如:
npx serve .
打开页面后:
- 点击“连接钱包并登录”
- 获取 challenge
- 钱包弹窗展示 EIP-712 登录消息
- 用户确认签名
- 服务端验签成功并返回 JWT
- 调用
/me验证登录状态
如果你用 React + ethers / wagmi,核心调用是什么
如果你不想直接使用 window.ethereum.request,可以用库封装。核心逻辑仍然是:
const signature = await signer.signTypedData(domain, types, message);
例如 ethers v6:
const signature = await signer.signTypedData(
typedData.domain,
typedData.types,
typedData.message
);
注意:有些库要求你 不要传入 EIP712Domain 类型定义,否则会报错。这个兼容性坑非常常见。
容量估算与系统取舍
对于登录 challenge 服务,容量主要取决于:
- nonce 存储数量
- challenge TTL
- 登录峰值 QPS
- JWT 签发频率
粗略估算
假设:
- 峰值登录请求 200 QPS
- challenge 有效期 5 分钟
- 平均每条 challenge 存储 1 KB
则同时在库 challenge 数量大约:
200 * 300 = 60000
占用存储约:
60000 * 1KB ≈ 60MB
这对 Redis 来说是完全可接受的。
Redis 还是数据库
Redis 适合:
- challenge 短期存储
- 高频读写
- TTL 自动过期
- 原子消费操作
PostgreSQL/MySQL 适合:
- 审计留痕
- 风险分析
- 登录行为追踪
- 后台排查
我的建议是:
- 在线认证状态放 Redis
- 审计日志异步落数据库
这样一般比较均衡。
常见坑与排查
这一部分非常重要。我见过很多“签名不通过”的问题,最后都不是密码学错误,而是字段不一致。
1. 前后端 typedData 不一致
最常见的坑:
chainId前端和后端不一致message.wallet地址大小写格式不同- 时间字段一个是 ISO 字符串,一个是 Unix 时间戳
- 字段顺序或类型不一致
primaryType写错
排查办法:
- 服务端把最终参与验签的
typedData原样打印 - 前端把传给钱包的 JSON 也打印
- 对比每个字段,尤其是
domain和types
建议直接规定:
前端不自己拼 typedData,由后端返回完整结构。
这样能减少大量协作误差。
2. eth_signTypedData_v4 与库封装差异
有的钱包注入接口要:
params: [address, JSON.stringify(typedData)]
而 ethers 的 signTypedData 则是:
signTypedData(domain, types, message)
如果你把 typedData 整体直接塞进去,通常会报参数格式错误。
3. types 里误传 EIP712Domain
在使用 ethers.verifyTypedData 时,很多时候 types 应该只传业务类型,比如:
{
Login: [...]
}
不要额外传:
EIP712Domain: [...]
否则不同库行为可能不一致。
4. 地址比较没有标准化
以太坊地址最好统一处理:
ethers.getAddress(address)
不要直接字符串比较原始输入。
因为用户提交的小写地址、校验和地址、钱包返回地址可能格式不同。
5. nonce 已过期但前端还在用旧 challenge
这在移动端切后台、用户长时间不确认签名时很常见。
建议:
- challenge TTL 设为 3~5 分钟
- 前端遇到
challenge expired自动重新拉取 challenge - UI 明确提示“签名已过期,请重试”
6. 并发重放
一个非常隐蔽的坑是:
- 同一份签名几乎同时打到两个服务实例
- 两边都先查到
pending - 两边都成功验签
- 两边都签发 token
这说明你的 nonce 消费不是原子的。
正确做法
在 Redis 里可以用:
SETNX- Lua 脚本
- 事务 / CAS
在数据库里可以用:
UPDATE ... WHERE status='pending'- 然后检查影响行数是否为 1
如果不是 1,就说明这次 challenge 已被别人消费。
安全最佳实践
1. 登录签名和交易签名彻底分离
不要让同一套消息结构既能做登录,又能被解释成链上授权意图。
登录签名应该只表达:
- 认证
- 会话建立
- 风险确认
不要夹带:
- Token 授权
- Permit
- Delegate
- 合约调用意图
2. 严格设置过期时间
建议:
- challenge 有效期:3~5 分钟
- JWT 有效期:1~2 小时
- 高敏操作额外要求重新签名
长会话不是不能做,但要结合刷新机制和风控。
3. 把域信息写清楚
建议固定这些信息:
nameversionchainIduristatement
并确保用户在钱包里能看懂签名用途。
一句人话,比十个技术字段更能降低误签率。
4. 做好风控与审计
建议记录:
- 钱包地址
- challengeId
- nonce
- IP
- User-Agent
- 签名时间
- 验签结果
- 拒绝原因
这样出了问题,你至少能回答:
- 是签名字段错了?
- 是 nonce 重放?
- 是前端拿旧 challenge?
- 还是用户钱包网络不匹配?
5. 生产环境不要用内存存储 nonce
单机 Demo 可以,生产环境绝对不建议。
因为内存存储会有这些问题:
- 服务重启 challenge 全丢
- 多实例之间状态不一致
- 无法原子消费
- 审计困难
最小可用方案是:
- Redis 存 challenge + TTL + 原子消费
- PostgreSQL 存审计日志
6. 明确链切换策略
如果你登录只接受某条链,比如 Ethereum Mainnet:
- 前端先检查当前钱包网络
- 不匹配时提示切链
- 服务端也必须校验
domain.chainId
不要只做前端限制。
因为安全边界永远不能只靠前端。
性能最佳实践
1. Challenge 接口尽量无状态化
除了 nonce 存储外,/auth/challenge 尽量做成轻逻辑:
- 生成 nonce
- 写缓存
- 返回 typedData
不要在这里夹杂太多链上查询。
链上权限判断可以放到登录成功后再做,或者异步缓存。
2. 验签很快,瓶颈多半不在密码学
verifyTypedData 本身开销一般不大。
真正的瓶颈通常是:
- Redis/数据库写入
- 审计日志
- 下游用户信息初始化
- 风控服务调用
所以优化重点应该放在:
- 减少同步链路
- 审计异步化
- 用户画像延迟加载
3. 对 challenge 做自动清理
如果用 Redis,直接设置 TTL。
如果用数据库,定时任务清理 expired 和 오래未使用记录。
例如:
DELETE FROM login_challenge
WHERE created_at < NOW() - INTERVAL '7 days';
一个更稳的生产版设计建议
如果你已经进入正式业务,我建议把这套系统拆成这几个模块:
auth-api:challenge 签发、签名验证、JWT 签发nonce-store:Redis,负责短期状态和原子消费audit-log:数据库,负责留痕policy-engine:可选,负责黑名单、限流、设备/IP 风险评分permission-service:可选,从链上或索引服务同步 NFT / Role / Token 状态
这样做的好处是:
- 登录链路保持短
- 权限与身份解耦
- 风险控制可独立扩展
- 审计数据更完整
边界条件:什么时候这套方案不够
虽然 EIP-712 登录已经很实用,但它也不是万能的。
以下场景你可能要继续加能力:
1. 需要“会话持续签名”能力
比如高敏操作要再次确认,可以引入:
- 二次签名确认
- session key
- passkey / WebAuthn 绑定
2. 需要多链统一身份
如果一个用户可能用同一钱包在多条 EVM 链活动,你需要单独设计:
- 多链地址映射
- 统一账户模型
- 跨链权限聚合
3. 需要合约钱包支持
EOA 钱包验签可以直接恢复地址。
但合约钱包往往要考虑:
- ERC-1271
- 智能账户签名验证
- Bundler / AA 场景
如果你的用户中合约钱包比例上升,服务端就不能只靠 recoverAddress,还需要支持 ERC-1271 签名有效性校验。
总结
把钱包签名做成“能登录”并不难,难的是把它做成一套 可靠、可扩展、能防重放 的认证系统。
这篇文章的核心结论可以浓缩成 5 点:
- 用 EIP-712 替代随意字符串签名
- challenge 必须包含 nonce、过期时间、域信息、链信息
- 服务端必须原子消费 nonce,防并发重放
- 登录认证链下完成,链上更多提供权限依据
- 生产环境用 Redis + 审计数据库,不要用内存状态
如果你准备真正落地,我建议按这个顺序推进:
- 第一步:后端统一生成 typedData,前端不自己拼
- 第二步:nonce 加 TTL,并支持一次性消费
- 第三步:登录成功后签发 JWT/session
- 第四步:补审计、限流、风控
- 第五步:评估 ERC-1271 和多链支持
一句实用建议收尾:
钱包登录不是“验签成功就结束”,而是“验签只是认证协议的开始”。
只有把 nonce、域隔离、过期、并发消费和会话管理都串起来,这套系统才算真正能上生产。