Web3 钱包登录实战:用 SIWE(Sign-In with Ethereum)构建安全的去中心化身份认证系统
在传统 Web 应用里,登录这件事几乎默认等于“账号 + 密码”,后来演进成“手机号 + 验证码”或者“OAuth 第三方登录”。但到了 Web3 场景,用户并不天然希望再创建一个中心化账号体系——他们已经有钱包地址,也掌握私钥,最自然的想法就是:能不能直接用钱包完成身份认证?
答案是可以,而且现在业内相对标准的方案就是 SIWE(Sign-In with Ethereum)。
这篇文章我会从架构视角带你走一遍:为什么要用 SIWE、它到底解决了什么问题、完整登录链路怎么设计、代码怎么写、线上会踩哪些坑,以及如何把它做得更安全、更稳定。
背景与问题
为什么“钱包地址 = 身份”还不够?
很多刚接触 Web3 登录的同学会有一个直觉:
“用户把钱包地址发给后端,不就知道他是谁了吗?”
这其实不成立。因为地址是公开信息,任何人都可以声称“我是这个地址的主人”。真正的问题不是“这个地址是什么”,而是:
你怎么证明你控制了这个地址对应的私钥?
这就是签名登录存在的必要性。
传统登录体系在 Web3 中的几个不适配点
-
密码体系不符合用户习惯
Web3 用户已经习惯用钱包管理身份,不愿再记一套站内密码。 -
OAuth 依赖中心化身份提供商
Google、GitHub 登录很好用,但它们不是链上身份,也不天然适用于链上权限模型。 -
纯地址登录缺少抗重放能力
如果没有 nonce、domain、expiration 等约束,历史签名可能被重放。 -
前后端很容易各写一套“自定义签名协议”
这类方案最常见的问题是格式不统一、字段不完整、兼容性差,后期很难维护。
SIWE 解决了什么?
SIWE 本质上是一个标准化的以太坊签名登录协议。它让“钱包签名认证”这件事从“随便拼一段字符串”变成了:
- 有统一消息格式
- 有明确字段语义
- 支持 nonce、防重放
- 支持 domain / URI / chainId / expiration 等约束
- 便于前后端和多钱包生态兼容
方案概览与架构设计
从架构上看,SIWE 不是“用户签个名就登录了”这么简单,而是一条完整的认证链路:
- 前端请求服务端生成
nonce - 前端构造 SIWE message
- 用户在钱包中签名
- 前端把
message + signature发回服务端 - 服务端验证签名、校验 nonce、domain、时间窗口等
- 验证成功后,服务端签发自己的会话令牌(通常是 session cookie 或 JWT)
这里有一个非常重要的设计原则:
钱包签名只用于“证明身份”,真正的应用会话仍应由你的后端统一管理。
也就是说,不要每次请求业务接口都强迫用户重新签名。那样交互体验会非常差,也容易造成签名疲劳。
登录架构图
flowchart TD
A[前端 DApp] --> B[后端 API]
B --> C[生成 nonce 并写入会话]
A --> D[钱包]
D --> A
A --> B
B --> E[验证 SIWE 消息与签名]
E --> F[签发 Session/JWT]
F --> G[受保护业务接口]
时序图:一次完整登录
sequenceDiagram
participant U as 用户
participant F as 前端
participant W as 钱包
participant S as 服务端
U->>F: 点击“使用以太坊登录”
F->>S: GET /auth/nonce
S-->>F: nonce
F->>W: signMessage(SIWE message)
W-->>F: signature
F->>S: POST /auth/verify { message, signature }
S->>S: 校验签名/nonce/domain/时间
S-->>F: session 或 JWT
F->>S: 携带会话访问受保护资源
S-->>F: 返回业务数据
核心原理
SIWE 消息里通常包含什么?
典型字段包括:
domain:当前登录域名address:用户钱包地址statement:给用户看的说明文字uri:当前应用 URIversion:通常为1chainId:链 IDnonce:随机数,防重放issuedAt:签发时间expirationTime:可选,过期时间notBefore:可选,生效时间
这些字段不是摆设,它们决定了安全边界。
比如:
domain防止签名被别的站点滥用nonce防止同一签名被重复提交chainId帮助你区分用户在哪条链上下文中完成登录expirationTime限制签名长期有效带来的风险
验证过程本质是什么?
服务端拿到 message + signature 后,会做几类校验:
-
密码学校验
验证这个签名是否真的由address对应私钥产生。 -
协议字段校验
核对domain、uri、chainId、version是否符合预期。 -
会话状态校验
确认nonce是服务端刚刚发出去的,并且尚未使用。 -
时间窗口校验
校验issuedAt、expirationTime、notBefore是否有效。
它和“链上交易签名”有什么不同?
很多人第一次做钱包登录会混淆两类签名:
- 交易签名:要上链、要消耗 gas、会改变链上状态
- 消息签名:只是在本地对一段消息签名,不会上链、不消耗 gas
SIWE 使用的是消息签名,因此登录体验更轻量。
方案对比与取舍分析
方案一:自定义字符串签名
优点:
- 上手快
- 不依赖标准库也能写
缺点:
- 字段定义不统一
- 容易漏掉 nonce / domain / 过期时间
- 多端协作时容易出现格式不一致
方案二:SIWE 标准登录
优点:
- 标准化
- 安全边界更清晰
- 生态兼容性更好
- 适合长期演进
缺点:
- 前后端都要按标准实现
- 对字段理解要求更高
方案三:钱包直连 + 仅前端鉴权
优点:
- 省掉后端认证逻辑
缺点:
- 不适合需要服务端权限控制的系统
- 很难做稳定的会话管理
- 安全性和可审计性都偏弱
我的建议是:只要你的应用有后端、有用户权限、有订单/资产/订阅等状态,就不要走“纯前端假登录”路线,直接上 SIWE + 服务端会话。
实战代码(可运行)
下面我用一个中等复杂度、但可以直接运行的示例来演示:
- 前端:
React + ethers - 后端:
Node.js + Express - 核心库:
siwe
目录结构
siwe-demo/
server/
index.js
package.json
client/
App.jsx
package.json
后端:Express 验证 SIWE
先安装依赖:
mkdir server && cd server
npm init -y
npm install express express-session cors siwe ethers
server/index.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();
app.use(express.json());
app.use(
cors({
origin: "http://localhost:5173",
credentials: true,
})
);
app.use(
session({
name: "siwe.sid",
secret: "replace-with-a-strong-secret",
resave: false,
saveUninitialized: true,
cookie: {
httpOnly: true,
secure: false,
sameSite: "lax",
},
})
);
app.get("/auth/nonce", (req, res) => {
const nonce = generateNonce();
req.session.nonce = nonce;
res.json({ nonce });
});
app.post("/auth/verify", async (req, res) => {
try {
const { message, signature } = req.body;
if (!message || !signature) {
return res.status(400).json({ ok: false, error: "缺少 message 或 signature" });
}
const siweMessage = new SiweMessage(message);
const result = await siweMessage.verify({
signature,
nonce: req.session.nonce,
domain: "localhost:5173",
});
if (!result.success) {
return res.status(401).json({ ok: false, error: "SIWE 验证失败" });
}
req.session.siwe = {
address: siweMessage.address,
chainId: siweMessage.chainId,
nonce: req.session.nonce,
};
req.session.nonce = null;
return res.json({
ok: true,
address: siweMessage.address,
chainId: siweMessage.chainId,
});
} catch (err) {
return res.status(401).json({
ok: false,
error: err.message || "验证异常",
});
}
});
app.get("/me", (req, res) => {
if (!req.session.siwe) {
return res.status(401).json({ ok: false, error: "未登录" });
}
res.json({
ok: true,
user: req.session.siwe,
});
});
app.post("/auth/logout", (req, res) => {
req.session.destroy(() => {
res.clearCookie("siwe.sid");
res.json({ ok: true });
});
});
app.listen(3001, () => {
console.log("SIWE server running at http://localhost:3001");
});
启动服务:
node index.js
前端:React 发起钱包签名登录
安装依赖:
mkdir client && cd client
npm init -y
npm install react react-dom ethers siwe
如果你用的是 Vite,可以再安装:
npm install vite
client/App.jsx:
import React, { useState } from "react";
import { BrowserProvider } from "ethers";
import { SiweMessage } from "siwe";
export default function App() {
const [address, setAddress] = useState("");
const [status, setStatus] = useState("未登录");
async function login() {
try {
if (!window.ethereum) {
alert("请先安装 MetaMask");
return;
}
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const userAddress = await signer.getAddress();
const nonceResp = await fetch("http://localhost:3001/auth/nonce", {
credentials: "include",
});
const { nonce } = await nonceResp.json();
const message = new SiweMessage({
domain: window.location.host,
address: userAddress,
statement: "登录当前应用,不会发起链上交易。",
uri: window.location.origin,
version: "1",
chainId: 1,
nonce,
});
const messageText = message.prepareMessage();
const signature = await signer.signMessage(messageText);
const verifyResp = await fetch("http://localhost:3001/auth/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
message: messageText,
signature,
}),
});
const data = await verifyResp.json();
if (!data.ok) {
throw new Error(data.error || "登录失败");
}
setAddress(data.address);
setStatus("已登录");
} catch (err) {
setStatus(`登录失败:${err.message}`);
}
}
async function fetchMe() {
const resp = await fetch("http://localhost:3001/me", {
credentials: "include",
});
const data = await resp.json();
if (data.ok) {
setStatus(`当前登录地址:${data.user.address}`);
} else {
setStatus("未登录或会话失效");
}
}
async function logout() {
await fetch("http://localhost:3001/auth/logout", {
method: "POST",
credentials: "include",
});
setAddress("");
setStatus("已退出");
}
return (
<div style={{ padding: 24, fontFamily: "sans-serif" }}>
<h1>SIWE 登录示例</h1>
<button onClick={login}>使用以太坊钱包登录</button>
<button onClick={fetchMe} style={{ marginLeft: 12 }}>
查看当前会话
</button>
<button onClick={logout} style={{ marginLeft: 12 }}>
退出
</button>
<p>状态:{status}</p>
{address && <p>地址:{address}</p>}
</div>
);
}
登录状态机视角
当系统复杂一点时,我建议不要只把 SIWE 当成一个接口,而要把它当成一个状态机。这样你在处理重试、过期、登出时会更稳。
stateDiagram-v2
[*] --> Anonymous
Anonymous --> NonceIssued: 请求 nonce
NonceIssued --> Signing: 钱包签名
Signing --> Verifying: 提交 message + signature
Verifying --> Authenticated: 验证成功
Verifying --> Anonymous: 验证失败
Authenticated --> Anonymous: 登出/会话过期
容量估算与会话设计建议
如果你把 SIWE 登录做成正式生产方案,需要提前考虑两个问题:
1. nonce 存哪里?
常见做法:
- 存在服务端 session
- 存在 Redis
- 存在数据库短期表
如果你的系统是单机 demo,express-session 足够。
如果是多实例部署,一定不要把 nonce 只放在单机内存里,否则请求打到不同实例就会验证失败。
我更推荐:
- 小规模:Redis + TTL
- 中规模以上:Redis + 会话统一管理
- 对审计要求高:数据库留登录事件日志,nonce 本体仍放 Redis
2. 会话令牌用 Cookie 还是 JWT?
Cookie Session
优点:
- 服务端可控,便于失效
- 适合 Web 应用
缺点:
- 分布式下要做 session 存储共享
JWT
优点:
- 无状态,扩展方便
- 适合前后端分离、多终端 API
缺点:
- 提前吊销比较麻烦
- 容易被误用成“长期有效万能票据”
如果你的前端就是浏览器 DApp,我建议优先用 HttpOnly Cookie + 服务端 Session/Redis。
这样可以减少 token 在浏览器侧暴露的风险。
常见坑与排查
这部分我自己踩过不少,基本都是“明明签了名,为什么后端说不对”。
1. domain 不一致
现象:
- 前端构造 message 时
domain是localhost:5173 - 后端验证时配置成了
localhost
结果一定会失败。
排查建议:
- 打印前端
message.prepareMessage() - 打印后端
new SiweMessage(message)解析结果 - 确认端口、协议、子域名完全一致
2. nonce 被重复使用
现象:
- 第一次登录成功
- 第二次拿同一条 message 重发,后端校验失败
这其实是正常的。nonce 就该一次性使用。
建议:
- 验证成功后立刻销毁 nonce
- 如果要支持重试,应该重新申请 nonce
3. 链 ID 配错
现象:
- 用户当前钱包连的是测试链
- 前端 message 里写死了
chainId: 1
有些钱包会允许签,但你的业务含义已经错了。
建议:
const network = await provider.getNetwork();
const chainId = Number(network.chainId);
然后把它写入 SIWE message,不要手写常量,除非你明确只支持某一条链。
4. CORS 和 Cookie 没配好
现象:
/auth/nonce请求成功/auth/verify也返回成功- 但
/me永远提示未登录
常见原因:
- 前端 fetch 没加
credentials: "include" - 服务端 CORS 没开
credentials: true - Cookie 的
sameSite/secure配置不匹配
开发环境里我一般先这么配:
cors({
origin: "http://localhost:5173",
credentials: true,
})
5. 反向代理后 domain 校验异常
现象:
- 本地正常
- 上线后 Nginx / CDN 后,验证失败
原因通常是:
- 前端看到的域名和后端实际校验域名不一致
- 例如用户访问的是
app.example.com,后端却拿内部域名校验
建议:
- 明确生产环境的“对外 canonical domain”
- 不要拿服务内部地址做 SIWE domain 校验
6. 钱包签名方法混用
不同钱包、不同库在 personal_sign、eth_sign、signMessage 上兼容性存在差异。
SIWE 通常走的是标准消息签名链路,前端尽量使用成熟库封装,而不是手搓 RPC 调用。
安全最佳实践
这一节最关键。SIWE 登录能不能上线,不在于“能不能签”,而在于“边界是否收住”。
1. 一定校验 nonce
这是防重放的第一道门槛。
如果你只验证签名真假,不验证 nonce,那么别人截获历史签名后就可能重放登录。
2. 一定校验 domain 和 uri
这能防止签名被跨站复用。
我见过一些实现只看 address 和 signature,这相当于把签名变成“通用令牌”,风险很大。
3. 给签名设置时间窗口
建议使用:
issuedAtexpirationTime- 必要时加
notBefore
这样即使签名泄漏,攻击窗口也更小。
4. 验证通过后签发自己的短期会话
不要把“签名结果”本身当作长期业务凭证。
正确做法是:
- SIWE 用于建立认证
- 服务端再发短期 session
- 高风险操作再要求二次校验
5. 高风险操作不要只靠登录态
比如:
- 提现
- 修改绑定地址
- 导出敏感数据
- 执行管理员操作
建议增加:
- 二次 SIWE challenge
- 风险提示文案
- 更短的有效期
- 设备/IP 异常检测
6. 防止签名提示文案误导用户
statement 字段应明确告诉用户:
- 这是登录行为
- 不会发起链上交易
- 不会消耗 gas
用户一旦看不懂签名内容,就更容易被钓鱼站利用。
7. 会话存储要支持失效与审计
生产环境至少要有:
- 主动登出
- 会话过期
- 登录日志
- 失败验证日志
- nonce 消耗记录
性能与工程实践
SIWE 的签名验证本身并不算特别重,但真实系统中的性能瓶颈通常不在密码学校验,而在状态管理和分布式一致性。
我建议的工程落地方式
小规模应用
- 单体后端
- Redis 存 session / nonce
- Cookie 会话
中等规模应用
- API 网关
- 认证服务独立
- Redis 集中式会话
- 登录事件异步写库
多业务线平台
- SIWE 认证中心
- 下游服务只信任认证中心签发的内部令牌
- 统一用户地址映射、权限模型、审计体系
一个可参考的认证分层
classDiagram
class FrontendDApp {
+requestNonce()
+buildSIWEMessage()
+requestWalletSignature()
+submitVerification()
}
class AuthService {
+generateNonce()
+verifySIWE()
+issueSession()
+revokeSession()
}
class SessionStore {
+saveNonce()
+consumeNonce()
+saveSession()
+deleteSession()
}
class BusinessAPI {
+checkSession()
+authorize()
}
FrontendDApp --> AuthService
AuthService --> SessionStore
BusinessAPI --> SessionStore
这个分层有个好处:
业务服务不需要理解钱包签名细节,它只需要理解“这个请求对应哪个已认证用户”。
边界条件:SIWE 不是万能身份系统
虽然 SIWE 很适合 Web3 登录,但也不要神化它。
它能证明的是:
- 这个用户当前控制某个钱包地址
它不能自动证明的是:
- 这个地址背后一定是某个自然人
- 这个人是否唯一
- 这个地址是否长期稳定归属于同一主体
- 这个地址是否满足你的业务实名、风控、KYC 要求
所以如果你的业务是:
- 金融合规
- 企业管理后台
- 强实名场景
那 SIWE 应该是身份入口之一,而不是全部身份体系。
总结
如果你要在 Web3 应用里设计登录体系,SIWE 是目前非常值得采用的标准方案。它的核心价值不只是“让用户用钱包登录”,而是把这件事做成了一个可验证、可扩展、可维护的认证架构。
你可以记住这几个落地要点:
- 钱包签名只负责证明地址控制权
- 登录成功后必须签发你自己的会话
- nonce、domain、时间窗口一定要校验
- 多实例部署时,把 nonce/session 放到共享存储
- 高风险操作不要只依赖普通登录态
如果你现在正准备上线一个带后端的 DApp,我的建议很直接:
- 不要自创签名协议,优先采用 SIWE
- 不要让前端单独承担认证逻辑
- 不要把签名当长期 token 使用
- 先把登录链路跑通,再补齐会话、安全、审计
这样做,系统不会一开始就“看起来很 Web3,实际上很脆弱”。相反,它会更像一个真正能承受生产流量和安全要求的身份认证系统。