Web3 中级实战:基于 EIP-712 与钱包签名实现安全的链上登录与授权流程
很多团队第一次做 Web3 登录,都是从“让用户签个消息”开始的:前端调钱包、用户点确认、后端验签、签发 session。这个思路没错,但真正上线后,问题很快就会冒出来:
- 文本签名内容不结构化,用户看不懂自己签了什么
- 不同钱包对
personal_sign支持表现不一致 - 容易遗漏
nonce、过期时间、域隔离,导致重放风险 - 登录和授权混在一起,越做越乱
- 前端能跑,后端验签总失败,排查成本很高
这篇文章我换一个更偏“落地工程”的角度,带你做一套可运行的 EIP-712 登录与授权流程:
不仅讲“怎么签”,还讲为什么这么设计、后端怎么验证、怎么避免把登录系统做成半个安全事故。
背景与问题
在 Web2 里,登录通常靠用户名密码、短信验证码或 OAuth。到了 Web3,用户身份的起点变成了钱包地址的控制权。
也就是说:谁能对某个地址对应的私钥完成签名,谁就能证明自己“是这个地址的持有者”。
但“证明地址所有权”只是第一步。真正的业务系统还需要解决这些问题:
- 登录:让后端知道“这个请求确实来自该钱包用户”
- 授权:让系统知道“用户授权了某个动作”
- 防重放:防止旧签名被别人拿去重复使用
- 可读性:让用户在钱包里看到清晰、可审计的签名内容
- 跨环境隔离:测试网、主网、不同站点之间不能串签
如果你还在直接签一段字符串,比如:
Login to my dapp: 0x123...
那它通常有几个明显短板:
- 没有结构化字段,钱包展示不友好
- 没有强约束域名、链 ID、版本
- 容易遗漏 nonce、deadline
- 后端对签名意图的语义理解弱
这就是 EIP-712 要解决的核心问题。
前置知识与环境准备
本文用一套最常见的技术栈:
- 前端:React +
ethers - 后端:Node.js + Express +
ethers - 钱包:MetaMask 或兼容 EIP-712 的钱包
- 目标:实现两类签名
- 登录签名:建立服务端会话
- 授权签名:对某个业务动作进行一次性授权
安装依赖:
mkdir web3-eip712-auth && cd web3-eip712-auth
npm init -y
npm install express cors cookie-parser jsonwebtoken ethers
如果你要做前端示例:
npm install react react-dom ethers
核心原理
1. EIP-712 在解决什么
EIP-712 的本质,是让“要签的数据”变成结构化、可读、可验证的数据对象,而不是一坨自由文本。
它把签名对象拆成三部分:
domain:签名域,限定这个签名属于哪个应用、哪个链、哪个版本types:结构体定义message:实际签名数据
这样钱包在展示时,会更像“你正在签署一份表单”,而不是“你正在签一段看不懂的字符串”。
2. 为什么它更适合登录与授权
因为登录/授权这类场景,本来就天然是“有字段、有语义”的:
walletnonceissuedAtexpirationTimestatementuriactionresourceId
这些字段非常适合结构化表达。
3. 登录与授权要分开建模
这是我很建议中级开发者尽早建立的习惯:
“登录”是身份确认,“授权”是业务同意。不要混成一个签名。
比如:
- 登录签名:
LoginMessage - 授权签名:
ActionAuthorization
这样后端逻辑会清楚很多,审计也容易。
一图看懂完整流程
flowchart TD
A[前端请求登录挑战] --> B[后端生成 nonce 和 EIP-712 message]
B --> C[前端调用钱包 signTypedData]
C --> D[用户确认签名]
D --> E[前端提交 address + signature + message]
E --> F[后端验证 EIP-712 签名]
F --> G[校验 nonce/域/过期时间]
G --> H[签发 session 或 JWT]
登录流程的结构设计
登录消息模型
我们先定义一个登录结构体:
LoginMessage(
wallet: address,
nonce: string,
issuedAt: string,
expirationTime: string,
statement: string,
uri: string
)
这里每个字段都不是摆设:
wallet:预期登录的钱包地址nonce:一次性随机挑战,防重放issuedAt:签发时间expirationTime:过期时间,限制签名生命周期statement:给用户看的登录说明uri:当前站点来源,帮助做域绑定
EIP-712 Domain 设计
推荐这样设计:
const domain = {
name: "MyDapp Auth",
version: "1",
chainId: 1,
verifyingContract: "0x0000000000000000000000000000000000000000"
};
对于纯链下登录,verifyingContract 常见有两种策略:
- 用零地址占位
- 用你的认证服务约定的固定地址标识
重点不是它必须真有合约,而是前后端必须一致。
我自己更倾向于:如果这是纯链下认证协议,就固定约定一个值并写入文档,不要随手乱填。
时序图:登录挑战与验签
sequenceDiagram
participant U as 用户
participant F as 前端 DApp
participant W as 钱包
participant B as 后端服务
U->>F: 点击钱包登录
F->>B: GET /auth/challenge?address=0x...
B->>B: 生成 nonce 与过期时间
B-->>F: 返回 domain/types/message
F->>W: signTypedData(domain, types, message)
W-->>F: signature
F->>B: POST /auth/verify
B->>B: recoverAddress + nonce 校验 + 时效校验
B-->>F: session/JWT
F-->>U: 登录成功
实战代码(可运行)
下面我给出一套最小可跑通的后端和前端示例。
为了便于演示,nonce 先放内存里。生产环境请放 Redis 或数据库。
后端:Express + ethers 实现挑战生成与验签
新建 server.js:
const express = require("express");
const cors = require("cors");
const cookieParser = require("cookie-parser");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const { ethers } = require("ethers");
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(
cors({
origin: "http://localhost:5173",
credentials: true
})
);
const PORT = 3001;
const JWT_SECRET = "replace-this-in-production";
// 演示用内存存储:address => challenge
const challengeStore = new Map();
const DOMAIN = {
name: "MyDapp Auth",
version: "1",
chainId: 1,
verifyingContract: "0x0000000000000000000000000000000000000000"
};
const LOGIN_TYPES = {
LoginMessage: [
{ name: "wallet", type: "address" },
{ name: "nonce", type: "string" },
{ name: "issuedAt", type: "string" },
{ name: "expirationTime", type: "string" },
{ name: "statement", type: "string" },
{ name: "uri", type: "string" }
]
};
function generateNonce() {
return crypto.randomBytes(16).toString("hex");
}
app.get("/auth/challenge", (req, res) => {
try {
const address = String(req.query.address || "").toLowerCase();
const chainId = Number(req.query.chainId || 1);
const uri = String(req.query.uri || "http://localhost:5173");
if (!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 = {
wallet: ethers.getAddress(address),
nonce,
issuedAt,
expirationTime,
statement: "Sign this message to login securely.",
uri
};
const domain = {
...DOMAIN,
chainId
};
challengeStore.set(address, {
nonce,
message,
domain,
createdAt: Date.now(),
used: false
});
return res.json({
domain,
types: LOGIN_TYPES,
primaryType: "LoginMessage",
message
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
app.post("/auth/verify", async (req, res) => {
try {
const { address, domain, message, signature } = req.body;
if (!address || !domain || !message || !signature) {
return res.status(400).json({ error: "Missing required fields" });
}
const normalizedAddress = String(address).toLowerCase();
const saved = challengeStore.get(normalizedAddress);
if (!saved) {
return res.status(400).json({ error: "Challenge not found" });
}
if (saved.used) {
return res.status(400).json({ error: "Challenge already used" });
}
if (saved.nonce !== message.nonce) {
return res.status(400).json({ error: "Nonce mismatch" });
}
if (saved.domain.chainId !== domain.chainId) {
return res.status(400).json({ error: "ChainId mismatch" });
}
if (saved.domain.name !== domain.name || saved.domain.version !== domain.version) {
return res.status(400).json({ error: "Domain mismatch" });
}
if (saved.message.uri !== message.uri) {
return res.status(400).json({ error: "URI mismatch" });
}
if (new Date(message.expirationTime).getTime() < Date.now()) {
return res.status(400).json({ error: "Message expired" });
}
const recovered = ethers.verifyTypedData(
domain,
LOGIN_TYPES,
message,
signature
);
if (ethers.getAddress(recovered) !== ethers.getAddress(address)) {
return res.status(401).json({ error: "Signature verification failed" });
}
saved.used = true;
const token = jwt.sign(
{
sub: ethers.getAddress(address),
type: "session"
},
JWT_SECRET,
{ expiresIn: "2h" }
);
return res.json({
success: true,
token,
address: ethers.getAddress(address)
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
const AUTH_TYPES = {
ActionAuthorization: [
{ name: "wallet", type: "address" },
{ name: "action", type: "string" },
{ name: "resourceId", type: "string" },
{ name: "nonce", type: "string" },
{ name: "deadline", type: "uint256" }
]
};
app.post("/auth/authorize-action", async (req, res) => {
try {
const { address, signature, payload } = req.body;
if (!address || !signature || !payload) {
return res.status(400).json({ error: "Missing fields" });
}
const domain = {
...DOMAIN,
chainId: payload.chainId || 1
};
if (Number(payload.deadline) < Math.floor(Date.now() / 1000)) {
return res.status(400).json({ error: "Authorization expired" });
}
const message = {
wallet: payload.wallet,
action: payload.action,
resourceId: payload.resourceId,
nonce: payload.nonce,
deadline: payload.deadline
};
const recovered = ethers.verifyTypedData(
domain,
AUTH_TYPES,
message,
signature
);
if (ethers.getAddress(recovered) !== ethers.getAddress(address)) {
return res.status(401).json({ error: "Invalid action authorization" });
}
return res.json({
success: true,
authorized: true,
action: payload.action,
resourceId: payload.resourceId
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
启动:
node server.js
前端:请求挑战并调用钱包签名
下面给一个简单的 React 组件示例。
新建 WalletLogin.jsx:
import React, { useState } from "react";
import { ethers } from "ethers";
export default function WalletLogin() {
const [account, setAccount] = useState("");
const [token, setToken] = useState("");
const [status, setStatus] = useState("");
async function connectWallet() {
if (!window.ethereum) {
alert("Please install MetaMask");
return;
}
const provider = new ethers.BrowserProvider(window.ethereum);
const accounts = await provider.send("eth_requestAccounts", []);
setAccount(accounts[0]);
}
async function loginWithEIP712() {
try {
setStatus("请求 challenge 中...");
if (!window.ethereum) throw new Error("No wallet found");
if (!account) throw new Error("Please connect wallet first");
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const network = await provider.getNetwork();
const chainId = Number(network.chainId);
const challengeResp = await fetch(
`http://localhost:3001/auth/challenge?address=${account}&chainId=${chainId}&uri=${encodeURIComponent(window.location.origin)}`
);
const challenge = await challengeResp.json();
if (!challenge.domain) {
throw new Error(challenge.error || "Failed to get challenge");
}
setStatus("等待钱包签名...");
const signature = await signer.signTypedData(
challenge.domain,
challenge.types,
challenge.message
);
setStatus("提交验签中...");
const verifyResp = await fetch("http://localhost:3001/auth/verify", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
address: account,
domain: challenge.domain,
message: challenge.message,
signature
})
});
const verifyResult = await verifyResp.json();
if (!verifyResult.success) {
throw new Error(verifyResult.error || "Verify failed");
}
setToken(verifyResult.token);
setStatus("登录成功");
} catch (err) {
setStatus(`失败:${err.message}`);
}
}
async function authorizeAction() {
try {
if (!window.ethereum) throw new Error("No wallet found");
if (!account) throw new Error("Please connect wallet first");
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const network = await provider.getNetwork();
const chainId = Number(network.chainId);
const domain = {
name: "MyDapp Auth",
version: "1",
chainId,
verifyingContract: "0x0000000000000000000000000000000000000000"
};
const types = {
ActionAuthorization: [
{ name: "wallet", type: "address" },
{ name: "action", type: "string" },
{ name: "resourceId", type: "string" },
{ name: "nonce", type: "string" },
{ name: "deadline", type: "uint256" }
]
};
const payload = {
wallet: account,
action: "POST_ARTICLE",
resourceId: "article:10001",
nonce: crypto.randomUUID(),
deadline: Math.floor(Date.now() / 1000) + 300,
chainId
};
const signature = await signer.signTypedData(domain, types, {
wallet: payload.wallet,
action: payload.action,
resourceId: payload.resourceId,
nonce: payload.nonce,
deadline: payload.deadline
});
const resp = await fetch("http://localhost:3001/auth/authorize-action", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
address: account,
signature,
payload
})
});
const result = await resp.json();
if (!result.success) {
throw new Error(result.error || "Authorization failed");
}
alert(`已授权动作: ${result.action}, 资源: ${result.resourceId}`);
} catch (err) {
alert(err.message);
}
}
return (
<div style={{ padding: 24 }}>
<h2>EIP-712 登录与授权演示</h2>
<p>当前账户:{account || "未连接"}</p>
<button onClick={connectWallet}>连接钱包</button>
<button onClick={loginWithEIP712} style={{ marginLeft: 12 }}>
EIP-712 登录
</button>
<button onClick={authorizeAction} style={{ marginLeft: 12 }}>
授权业务动作
</button>
<p style={{ marginTop: 16 }}>状态:{status}</p>
{token && (
<>
<p>JWT:</p>
<textarea
readOnly
value={token}
style={{ width: 600, height: 120 }}
/>
</>
)}
</div>
);
}
注意:
crypto.randomUUID()在现代浏览器可用。如果你项目环境较旧,改成后端下发 nonce 更稳妥。
授权流程为什么不能直接复用登录签名
很多人上线前会想偷懒:
“既然已经登录了,那用户后续敏感操作就不用再签了吧?”
这取决于业务风险。
如果只是普通页面浏览、个人设置读取,session 足够。
但如果是这些场景,我建议单独再做授权签名:
- 发布重要内容
- 发起订单撮合
- 提交链上交易前确认摘要
- 允许第三方服务代表用户执行动作
- 对资产、额度、权限做高风险操作
原因很简单:
登录证明“你是谁”,不等于你同意“现在执行这个动作”。
状态图:登录与动作授权的边界
stateDiagram-v2
[*] --> Unauthenticated
Unauthenticated --> ChallengeIssued: 请求登录挑战
ChallengeIssued --> SignedLogin: 钱包签名
SignedLogin --> Authenticated: 服务端验签成功
Authenticated --> ActionPending: 发起敏感动作
ActionPending --> ActionAuthorized: 签署授权消息
ActionAuthorized --> Authenticated: 动作执行完毕
Authenticated --> [*]
逐步验证清单
如果你希望边做边验证,而不是写完一大堆再一起崩,我建议按这个顺序:
第 1 步:只验证钱包连接
确认前端能拿到地址:
const accounts = await provider.send("eth_requestAccounts", []);
console.log(accounts[0]);
第 2 步:只拿 challenge,不签名
确认后端返回了完整的:
domaintypesmessage
第 3 步:本地打印签名对象
签名前输出:
console.log(JSON.stringify(challenge, null, 2));
这一步经常能看出链 ID、uri、字段名拼写问题。
第 4 步:签名后后端单独 recover
服务端先只做:
const recovered = ethers.verifyTypedData(domain, types, message, signature);
console.log(recovered);
确认 recover 出来的地址是对的,再继续做 nonce 校验、过期校验。
第 5 步:最后再接 JWT/session
不要一开始就把 cookie、session、权限系统全堆上去,不然排错范围太大。
常见坑与排查
这一节很重要。我自己做这类功能时,踩坑最多的不是“不会写”,而是前后端明明看起来一样,验签就是失败。
坑 1:前后端的 types 不完全一致
比如前端是:
{ name: "deadline", type: "uint256" }
后端误写成:
{ name: "deadline", type: "string" }
这种情况下,签名一定验不过。
排查建议:
- 前后端共用同一份 schema 定义
- 把
domain/types/message完整打印出来逐项比对 - 不要手工复制时改字段名
坑 2:chainId 不一致
前端钱包在 Polygon,后端却写死 chainId: 1。
验签时会直接失败。
排查建议:
前端拿钱包实际网络:
const network = await provider.getNetwork();
const chainId = Number(network.chainId);
后端 challenge 也必须基于这个 chainId 生成。
坑 3:地址大小写与校验和问题
用户地址可能是小写,也可能是 checksum 格式。
直接字符串比较,很容易误判。
建议:
统一用:
ethers.getAddress(address)
做标准化后再比较。
坑 4:nonce 没有“一次性消费”
如果签名验证通过后,nonce 还可以重复使用,那你其实把登录签名变成了“可回放通行证”。
建议:
- nonce 验签成功后立刻标记已使用
- nonce 要有 TTL
- nonce 与 address 绑定
- 最好支持多终端时的 challenge 隔离
坑 5:前端把 challenge 改了
有的同学在前端签名前,会“顺手改一下 message”,比如重写了 uri 或时间格式。
只要改过一个字符,签名对象就不是后端发的那个对象了。
建议:
challenge 返回后,前端应视为只读数据。
如果要修改,必须重新向后端申请 challenge。
坑 6:钱包支持差异
大多数主流钱包支持 EIP-712,但不同注入式钱包、移动端钱包、WalletConnect 中转场景,细节表现可能不同。
建议:
- 优先使用标准方法:
signer.signTypedData(...) - 针对移动端和 WalletConnect 单独做兼容测试
- 不要默认所有钱包对复杂嵌套 struct 都表现一致
坑 7:把登录签名长期复用
有些系统会把一次登录签名缓存下来,后续长期复用。
这会显著放大签名泄漏后的风险。
建议:
- 登录 challenge 只短时有效,如 5 分钟
- 登录 session 自己单独设过期
- 高风险动作必须重新授权签名
安全/性能最佳实践
这一节我尽量讲“能直接执行”的建议。
1. 登录签名必须包含 nonce 和过期时间
最低配置建议:
nonce:128 bit 随机值以上issuedAt:ISO 时间expirationTime:5 分钟内
如果没有过期时间,旧 challenge 很可能被误用。
2. 做域隔离,别让不同环境串签
domain 至少要稳定包含:
nameversionchainId
业务层最好再在 message 里加入:
uri- 或
audience - 或
origin
这样测试环境签名就不容易拿到生产环境复用。
3. 登录与授权分层
推荐分三层:
- 钱包地址认证层:EIP-712 登录
- 会话层:JWT / session cookie
- 动作授权层:对高风险操作单独签名
这是一个很实用的架构边界。
不要每次请求都强制钱包签名,否则用户体验会非常差;
也不要所有动作都只靠 session,否则高风险操作缺乏显式确认。
4. nonce 存 Redis,不要只放进程内存
本文演示用了 Map,但生产不要这么做。原因有三个:
- 服务重启后 challenge 丢失
- 多实例部署时 challenge 不共享
- 不利于统一 TTL 管理
生产建议:
- Redis key:
auth:challenge:${address}:${nonce} - TTL:300 秒
- 验签成功后立即删除
5. 只信后端生成的 challenge
前端不要自己拼登录消息再提交给后端验签。
否则你很难保证字段语义、时效、资源边界都正确。
正确做法是:
- 后端生成 challenge
- 前端只负责请求、签名、回传
- 后端严格按自己签发过的 challenge 验证
6. 对授权签名增加资源粒度
授权消息不要只写:
action = transfer
而应该更具体:
action = TRANSFERresourceId = vault:123amount = 1000000token = 0x...deadline = ...
签名越精确,滥用空间越小。
7. 注意时钟偏差
如果前端和后端、或服务节点之间时间差太大,过期判断可能误伤正常请求。
建议:
- 以后端时间为准生成 challenge
- 可以容忍几十秒的偏差窗口
- 排查时先打印服务器时间和 message 时间
8. 使用 HttpOnly Cookie 时注意 CSRF
如果你把登录后的 token 放在 cookie 里,而不是前端内存或 Authorization header,那么还要同时考虑 CSRF。
常见方案:
SameSite=Lax/Strict- CSRF token
- 敏感接口二次签名
别因为“都用钱包了”就忽略传统 Web 安全问题。
9. 为审计留证据,但不要记录敏感冗余
推荐记录:
- address
- nonce
- issuedAt
- expirationTime
- 签名 hash 或原始 signature
- recover 地址
- IP / User-Agent(按合规需要)
但不要把不必要的用户敏感上下文无限堆日志里。
一个更稳的生产化改造方向
如果你准备把这套方案真正用于线上,我建议至少做这些升级:
后端升级项
- challenge 存 Redis
- JWT secret 放环境变量
- 按地址限流挑战请求
- 支持 nonce 单次消费与黑名单
- 审计日志入库
- 对授权动作增加幂等键
前端升级项
- 登录前先校验链是否正确
- 对签名失败、拒签、钱包断连做明确提示
- 用统一 hooks 封装 challenge / sign / verify
- 对移动端钱包做兼容测试
协议升级项
- 明确定义 domain 规范
- 明确定义 message schema 版本
- 协议升级时保留
version - 必要时参考 SIWE(Sign-In with Ethereum)的字段设计思路
与 personal_sign 的取舍对比
不是说 personal_sign 完全不能用,而是它更适合:
- 临时调试
- 简单钱包所有权证明
- 不需要结构化展示的轻场景
而 EIP-712 更适合:
- 登录系统
- 权限授权
- 风险操作确认
- 需要可读、可审计、可扩展的签名协议
简单对比:
| 维度 | personal_sign | EIP-712 |
|---|---|---|
| 用户可读性 | 一般 | 更好 |
| 结构化 | 弱 | 强 |
| 钱包展示 | 不稳定 | 更清晰 |
| 字段扩展 | 差 | 好 |
| 登录授权场景 | 勉强可用 | 更推荐 |
如果你的项目是认真做产品,而不是 Demo,我更建议从 EIP-712 起步。
排查思路:验签失败时先看哪三样
如果你线上遇到“签名验不过”,我建议第一时间只盯住这三件事:
- domain 是否完全一致
- types 是否逐字段一致
- message 是否一字不差
很多时候不是密码学问题,就是对象不一致问题。
我自己排过最离谱的一次,是前端把 chainId 当字符串传了,后端按数字处理,最后看起来都像 1,但就是验不过。这个坑非常隐蔽。
总结
如果把这篇文章压缩成一句话,那就是:
用 EIP-712 做 Web3 登录,不只是“换一种签名方式”,而是在建立一套可读、可验证、可扩展的身份与授权协议。
你可以把落地方案记成这几个关键点:
- 登录签名和业务授权签名分开
- challenge 必须由后端生成
- 每次登录都要有 nonce 和过期时间
- 验签成功后 nonce 必须立即作废
domain/types/message前后端必须完全一致- 高风险操作单独做授权签名,不要只靠 session
- 生产环境把 challenge 放 Redis,并做好限流、审计、版本化
如果你现在的系统还停留在“签一段字符串就登录”,那下一步最值得做的升级,不是继续堆业务逻辑,而是先把签名协议结构化。
因为这个基础打牢了,后面你无论接 SIWE、接多钱包、接动作授权,都会顺很多。
如果要我给一个明确边界建议:
- 轻量 Demo:
personal_sign可以先用 - 正式登录系统:直接上 EIP-712
- 涉及敏感业务动作:EIP-712 登录 + EIP-712 动作授权双层设计
这套方式不算最短路径,但它通常是更稳、更容易长期维护的路径。