Web3 钱包接入实战:基于 EIP-4361 实现 Sign-In with Ethereum 登录系统
在 Web2 世界里,“登录”通常意味着用户名密码、短信验证码,或者 OAuth。到了 Web3,很多团队第一反应是:既然用户有钱包,那我让他签个名,不就算登录了吗?
思路没错,但如果直接“前端发一段固定字符串,用户签名,后端验签”就上线,后面大概率会遇到几个问题:
- 签名内容不规范,不同钱包兼容性差
- 容易被重放攻击
- 域名、链 ID、过期时间没绑定,安全边界模糊
- 前后端对“谁已经登录”理解不一致
- 多链、多环境、移动端钱包接入后,流程开始混乱
这也是 EIP-4361:Sign-In with Ethereum(SIWE) 的意义。它不是发明“签名登录”,而是把这件事标准化:什么内容该签、怎么表达上下文、如何绑定域名和时效,尽量减少“每家都造一个半成品协议”的情况。
这篇文章我不只讲“怎么签”,更从一个架构设计的角度,带你搭一套能上线的 SIWE 登录系统:前端连接钱包、后端生成 nonce、服务端验签、建立 session,再到常见坑和安全边界。
背景与问题
为什么不能“随便签一段字符串”?
很多项目早期都是这样的:
- 用户连接 MetaMask
- 前端让用户签名:
Login to xxx - 后端恢复地址,验证成功后发 token
看起来已经能跑,但问题在于:
1. 缺少上下文绑定
如果签名内容没明确声明:
- 当前域名
- 登录地址
- nonce
- 过期时间
- chainId
- 请求资源
那这段签名就可能在别的上下文里被复用。
2. 缺少抗重放能力
如果 nonce 不唯一、不过期、不消费,那么同一份签名可能被多次拿来换 token。
3. 前后端协作容易失真
前端发起签名时知道用户在哪个页面、哪个环境,但后端只看到“签名 + 地址”。没有统一格式时,很难稳定解析和审计。
4. 多钱包、多链兼容复杂
不同钱包对签名展示、链切换、消息编码细节都有差异。没有标准消息格式,排查问题会很痛苦。
方案概览:SIWE 登录系统应该长什么样
一个相对完整的 EIP-4361 登录架构,建议拆成四个职责:
- 前端应用:连接钱包、拉取 nonce、构造 SIWE message、请求签名、提交后端
- 认证服务:生成 nonce、校验签名、消费 nonce、创建 session/JWT
- 会话层:Cookie Session 或 JWT,承接后续接口鉴权
- 业务服务:只信任认证层输出的用户身份,不自行验签
整体流程
sequenceDiagram
participant U as 用户
participant W as 钱包
participant F as 前端应用
participant A as 认证服务
participant S as Session存储
U->>F: 点击“使用以太坊登录”
F->>W: 请求连接钱包
W-->>F: 返回地址
F->>A: GET /auth/nonce
A-->>F: 返回 nonce
F->>F: 生成 SIWE Message
F->>W: signMessage(message)
W-->>F: 返回 signature
F->>A: POST /auth/verify(message, signature)
A->>A: 解析消息、验签、校验 nonce/域名/时效
A->>S: 创建 session
A-->>F: 返回登录成功 + session cookie
F->>A: 请求业务接口
A-->>F: 基于 session 识别用户
核心原理
EIP-4361 的本质
SIWE 本质上是一个结构化登录声明。用户不是在签“任意字符串”,而是在签一份明确表达登录意图的声明,比如:
- 我是谁:钱包地址
- 我在登录哪个域名:domain
- 我接受哪个 URI 的登录上下文:uri
- 我在哪条链上:chainId
- 这次登录的随机挑战值:nonce
- 签名什么时候发起、什么时候过期:issuedAt / expirationTime
一个典型的 SIWE Message
example.com wants you to sign in with your Ethereum account:
0xAbC123...
Sign in to Example App
URI: https://example.com/login
Version: 1
Chain ID: 1
Nonce: kYz8aPq91X
Issued At: 2024-01-01T00:00:00.000Z
Expiration Time: 2024-01-01T00:05:00.000Z
这份消息有两个关键特点:
- 人能读懂:钱包弹窗里用户看到的是明确的登录声明
- 机器能解析:后端可以按标准字段校验
关键校验点
后端在验证签名时,不应该只做“签名恢复地址成功”这一件事,还要一起检查:
- 地址是否与 message 中 address 一致
- domain 是否是本站域名
- uri 是否在允许范围内
- chainId 是否符合预期
- nonce 是否存在、未使用、未过期
- expirationTime 是否过期
- notBefore(如果用了)是否尚未到生效时间
为什么 nonce 必须服务端生成
这是整个系统里非常容易被忽略的点。
如果 nonce 由前端生成,后端无法保证:
- 它是否真的随机
- 是否已使用过
- 是否被攻击者提前构造
所以标准做法是:
- 后端生成 nonce
- 存储 nonce 状态
- 验证成功后立刻消费
- 同一个 nonce 只能成功一次
架构设计与取舍分析
在实际落地里,我通常会把“钱包签名认证”和“业务登录态”分开看。前者解决身份证明,后者解决持续访问控制。
方案一:验签后建立服务端 Session
优点:
- 后续接口简单,沿用成熟的 Cookie Session 体系
- 可以方便做踢登录、风控、权限变更即时生效
- 业务服务不用重复解析钱包签名
缺点:
- 需要 session 存储或粘性会话
- 跨域部署时要处理 cookie 策略
适合:
- 中后台、Web 应用、同域站点
方案二:验签后签发 JWT
优点:
- 适合前后端分离、微服务
- 网关验证方便
缺点:
- 撤销复杂
- token 生命周期和刷新机制需要设计
适合:
- API 网关明确、客户端较多的系统
方案三:每次请求都让钱包签名
不推荐作为主登录方案。
原因很简单:
- 用户体验太差
- 钱包频繁弹窗
- 无法替代会话态管理
推荐架构
对于大多数产品,我更建议:
- SIWE 只负责首次认证
- 认证成功后切换到 Session 或 JWT
- 业务接口只认应用层登录态,不直接认钱包签名
flowchart LR
A[钱包签名认证 SIWE] --> B[认证服务验签]
B --> C[建立应用会话 Session/JWT]
C --> D[业务接口鉴权]
D --> E[权限/风控/审计]
实战代码(可运行)
下面给一套最小可运行示例:
- 前端:React + ethers + siwe
- 后端:Node.js + Express + express-session + siwe
为了聚焦流程,示例使用内存存储 nonce。生产环境请换成 Redis 或数据库。
目录结构
siwe-demo/
server.js
package.json
public/
index.html
安装依赖
npm init -y
npm install express express-session cors ethers siwe
后端实现
server.js
const express = require('express');
const session = require('express-session');
const cors = require('cors');
const crypto = require('crypto');
const { SiweMessage, generateNonce } = require('siwe');
const app = express();
const PORT = 3000;
// 演示用 nonce 存储,生产环境请使用 Redis
const nonceStore = new Map();
app.use(express.json());
app.use(cors({
origin: 'http://127.0.0.1:5500',
credentials: true
}));
app.use(session({
name: 'siwe.sid',
secret: 'replace-this-with-a-strong-secret',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: false, // 本地开发用 false,生产 HTTPS 环境改为 true
sameSite: 'lax',
maxAge: 1000 * 60 * 60 * 24
}
}));
app.get('/auth/nonce', (req, res) => {
const nonce = generateNonce();
const nonceId = crypto.randomUUID();
nonceStore.set(nonce, {
id: nonceId,
used: false,
createdAt: Date.now(),
expiresAt: Date.now() + 1000 * 60 * 5
});
res.json({ nonce });
});
app.post('/auth/verify', async (req, res) => {
try {
const { message, signature } = req.body;
if (!message || !signature) {
return res.status(400).json({ error: 'message and signature are required' });
}
const siwe = new SiweMessage(message);
const nonceRecord = nonceStore.get(siwe.nonce);
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' });
}
const result = await siwe.verify({
signature,
domain: '127.0.0.1:5500',
nonce: siwe.nonce
});
if (!result.success) {
return res.status(401).json({ error: 'signature verification failed' });
}
if (siwe.chainId !== 1) {
return res.status(400).json({ error: 'unexpected chainId' });
}
nonceRecord.used = true;
req.session.user = {
address: result.data.address,
chainId: result.data.chainId
};
res.json({
ok: true,
address: result.data.address,
chainId: result.data.chainId
});
} catch (err) {
console.error(err);
res.status(400).json({ error: err.message || 'verification failed' });
}
});
app.get('/me', (req, res) => {
if (!req.session.user) {
return res.status(401).json({ authenticated: false });
}
res.json({
authenticated: true,
user: req.session.user
});
});
app.post('/auth/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('siwe.sid');
res.json({ ok: true });
});
});
app.use(express.static('public'));
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
前端实现
public/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>SIWE Demo</title>
</head>
<body>
<h1>Sign-In with Ethereum Demo</h1>
<button id="loginBtn">连接钱包并登录</button>
<button id="meBtn">查看当前登录态</button>
<button id="logoutBtn">退出登录</button>
<pre id="output"></pre>
<script type="module">
import { ethers } from 'https://cdn.jsdelivr.net/npm/ethers@6.13.1/+esm';
import { SiweMessage } from 'https://cdn.jsdelivr.net/npm/siwe@2.3.2/+esm';
const output = document.getElementById('output');
function log(data) {
output.textContent = typeof data === 'string'
? data
: JSON.stringify(data, null, 2);
}
async function getNonce() {
const res = await fetch('http://localhost:3000/auth/nonce', {
credentials: 'include'
});
return res.json();
}
async function verify(message, signature) {
const res = await fetch('http://localhost:3000/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ message, signature })
});
return res.json();
}
document.getElementById('loginBtn').onclick = async () => {
try {
if (!window.ethereum) {
throw new Error('未检测到以太坊钱包,请安装 MetaMask');
}
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const address = await signer.getAddress();
const network = await provider.getNetwork();
const { nonce } = await getNonce();
const message = new SiweMessage({
domain: '127.0.0.1:5500',
address,
statement: 'Sign in to Example App',
uri: 'http://127.0.0.1:5500',
version: '1',
chainId: Number(network.chainId),
nonce,
issuedAt: new Date().toISOString(),
expirationTime: new Date(Date.now() + 1000 * 60 * 5).toISOString()
});
const preparedMessage = message.prepareMessage();
const signature = await signer.signMessage(preparedMessage);
const result = await verify(preparedMessage, signature);
log(result);
} catch (err) {
log({ error: err.message });
}
};
document.getElementById('meBtn').onclick = async () => {
const res = await fetch('http://localhost:3000/me', {
credentials: 'include'
});
log(await res.json());
};
document.getElementById('logoutBtn').onclick = async () => {
const res = await fetch('http://localhost:3000/auth/logout', {
method: 'POST',
credentials: 'include'
});
log(await res.json());
};
</script>
</body>
</html>
如何运行
启动服务
node server.js
打开页面
如果你本地有简单静态服务器,也可以直接由 Express 提供。访问:
http://localhost:3000
如果你改成了其他前端端口,记得同步修改:
- CORS 的
origin- SIWE message 中的
domainuri
代码背后的关键点
1. 前端不要自己“拼字符串”
我建议直接使用 siwe 库生成 message,而不是手写模板字符串。原因有两个:
- 字段格式更稳定
- 避免换行、字段名、顺序错误导致验签失败
2. 验签通过不等于登录完成
验签只是说明:
这个地址确实签了这份声明
真正的“登录完成”还包括:
- nonce 已消费
- 当前域名合法
- 会话已创建
- 风控规则通过
3. chainId 不是可有可无
用户连接在错误网络上时,很多团队会“反正签名也能验,就让他进”。这在业务上未必安全。
比如你只支持主网身份,却允许测试网环境签名,那后续资产、权限、NFT 读取逻辑可能全部偏掉。
登录状态模型
为了让系统行为更清晰,可以把 SIWE 登录看成一个状态机。
stateDiagram-v2
[*] --> Disconnected
Disconnected --> WalletConnected: 连接钱包
WalletConnected --> NonceIssued: 获取 nonce
NonceIssued --> Signed: 用户签名
Signed --> Verified: 服务端验签成功
Signed --> Failed: 验签失败/过期/重放
Verified --> SessionEstablished: 建立会话
SessionEstablished --> LoggedOut: 退出登录
Failed --> WalletConnected: 重新发起登录
LoggedOut --> Disconnected
这个状态机有助于处理前端交互细节,比如:
- 钱包已连接,但未登录
- 登录态已建立,但用户中途切了账户
- 钱包链切换后,当前会话是否仍然有效
常见坑与排查
这一部分我想讲得接地气一点,因为 SIWE 真正费时间的,往往不是“写代码”,而是调通最后 20%。
坑 1:domain 不匹配
现象:
- 后端验签时报 domain mismatch
- 本地开发环境特别常见
原因:
localhost:3000、127.0.0.1:3000、localhost是不同值- 端口也算 domain 上下文的一部分
排查方法:
- 打印前端生成的 message
- 看其中
domain:字段 - 与后端
verify({ domain })保持完全一致
建议:
- 本地统一用一个 host,不要一会儿 localhost 一会儿 127.0.0.1
坑 2:链 ID 不一致
现象:
- 用户钱包连接的是 Sepolia,但后端只接受 mainnet
- 验签通过,业务却异常
原因:
- 签名本身不强制链上交易,所以“签得出来”不代表“业务可接受”
排查方法:
- 前端打印
network.chainId - 后端记录
siwe.chainId
建议:
- 登录前就提示用户切到正确网络
- 后端仍然要做最终校验,不要只靠前端限制
坑 3:nonce 被重复使用
现象:
- 第一次登录成功
- 第二次用同一 message + signature 重放也成功
原因:
- nonce 没有落库
- 或者验签成功后忘了标记已消费
建议:
- nonce 存储必须有状态:
unused / used / expired - 验签成功后原子更新
- 分布式部署时不要把 nonce 放在单机内存里
坑 4:签名内容被改了一个换行,结果就不对
现象:
- 前端签名成功
- 后端怎么都验不过
原因:
- SIWE message 是精确文本
- 多一个空格、少一个换行、时间格式不同,都会导致验签失败
建议:
- 提交给后端的就是
prepareMessage()返回值 - 后端直接基于收到的 message 解析,不要再二次格式化
坑 5:Cookie 没带上,登录后 /me 还是未认证
现象:
/auth/verify返回成功- 调
/me却还是 401
原因通常是:
fetch没有加credentials: 'include'- CORS 未开启
credentials - cookie 的
sameSite或secure配置不对
排查方法:
- 打开浏览器开发者工具,看响应头里是否真的设置了 cookie
- 看后续请求是否带上 cookie
坑 6:钱包地址切换后,旧会话还在
这是一个架构上的“边界问题”。
如果用户:
- 用地址 A 登录
- 登录成功后在钱包里切换到地址 B
- 前端页面没刷新
那后端 session 仍然代表 A。此时“钱包当前地址”和“应用登录身份”已经分离。
建议:
- 前端监听钱包
accountsChanged事件 - 一旦地址变化,提示重新登录或主动登出
安全最佳实践
SIWE 不是“签个名”这么简单,安全上至少要守住以下几条。
1. nonce 必须短期有效且一次性消费
推荐:
- 过期时间 5 分钟左右
- 验签成功立即消费
- 失败过多可触发限流
2. 严格校验 domain 和 uri
不要因为“本地调试麻烦”就在生产里放宽为任意 domain。
否则你等于允许别的站点诱导用户签名后,拿到你这里登录。
3. 使用 HTTPS
尤其是:
- 前端页面
- 认证接口
- Session Cookie
生产环境必须:
secure: true- 只在 HTTPS 下传输 cookie
4. Session 与钱包地址绑定
会话里至少保存:
- address
- chainId
- loginAt
- sessionId
如果有更高安全要求,可以记录:
- User-Agent 指纹摘要
- IP 风险评分
- 最近验签时间
5. 给登录声明设置 statement
虽然 statement 不是强校验字段,但它直接影响用户在钱包弹窗里看到的文本。
建议写清楚用途,例如:
Sign in to Example AppAuthenticate to access your dashboard
不要写得含糊,更不要伪装成“转账确认”类措辞。
6. 不要把签名登录等同于链上资产授权
SIWE 是身份认证,不是 approve,也不是交易签名。
要让用户能明显区分:
- 登录签名
- 交易确认
- Permit/授权签名
这是产品安全感的重要来源。
性能与容量考虑
如果你的产品用户量上来,SIWE 登录链路的瓶颈通常不在“密码学验签”,而在外围状态管理。
1. nonce 存储的设计
生产建议用 Redis:
- key:
siwe:nonce:{nonce} - value:
unused - TTL: 300 秒
验证成功后使用原子操作修改状态,避免并发下重复消费。
2. 登录接口要做限流
攻击者可以高频请求:
/auth/nonce/auth/verify
建议按以下维度限流:
- IP
- 钱包地址
- Session 指纹
- User-Agent
3. 业务接口不要重复验签
这是很多团队早期常见误区。
如果每个业务请求都要求传 message + signature,再做一次验签:
- CPU 浪费
- 请求体膨胀
- 业务复杂度暴涨
正确做法是:
- 首次认证验签
- 后续走 session/jwt
4. 审计日志要保留关键字段
至少记录:
- address
- domain
- uri
- chainId
- nonce
- verify result
- sessionId
- timestamp
但注意不要把用户敏感上下文无限制落盘,尤其是在合规要求严格的场景里。
一套更稳妥的生产落地清单
如果你准备把它接到真实项目里,我建议至少检查下面这些项:
[ ] nonce 是否服务端生成
[ ] nonce 是否设置 TTL
[ ] nonce 是否一次性消费
[ ] 是否严格校验 domain
[ ] 是否校验 chainId
[ ] 是否校验 expirationTime
[ ] 是否使用 HTTPS
[ ] Session Cookie 是否为 HttpOnly + Secure
[ ] 是否监听 accountsChanged / chainChanged
[ ] 是否做登录接口限流
[ ] 是否有验签失败日志
[ ] 是否区分钱包连接态与应用登录态
什么时候 SIWE 不够用?
虽然 SIWE 很适合 Web3 登录,但也有边界:
1. 你需要“人”的身份,而不是“地址”的身份
SIWE 证明的是“这个地址控制者签了名”,它不直接证明:
- 用户真实姓名
- 手机号
- KYC 结果
这时你需要把钱包身份和业务身份做绑定。
2. 你需要强会话撤销能力
如果完全用长生命周期 JWT,不做黑名单或刷新机制,撤销就会困难。
对高风险系统,服务端 session 往往更稳妥。
3. 你需要跨钱包、跨链统一账户体系
一个用户可能有多个地址、多条链账户。
这时 SIWE 只是入口,后面还需要:
- 账户映射
- 主地址/子地址关系
- 多钱包绑定模型
总结
EIP-4361 的价值,不在于“让钱包登录这件事变得花哨”,而在于它给了我们一套标准、安全、可审计的登录声明格式。
如果你要把 SIWE 真正接进产品,我建议记住三个核心原则:
- 标准化消息,不手写野路子签名文本
- 验签只是开始,真正登录要靠 nonce + 会话管理闭环
- 业务接口只认应用层登录态,不要每次都重新玩签名
最后给一个最务实的落地建议:
- 小型项目:SIWE + 服务端 Session + Redis nonce
- 多服务项目:SIWE + 认证中心 + 短期 JWT/Session 双层设计
- 安全要求高:增加限流、地址变更重登、审计日志和风控策略
如果你现在正准备把“连接钱包”升级成“正式登录系统”,那 SIWE 基本就是最值得优先采用的方案。它不完美,但足够成熟,而且边界清晰。把这条链路搭对了,后面的 Web3 用户系统才不会越做越乱。