Web3 钱包登录实战:基于 SIWE(Sign-In with Ethereum)构建安全的去中心化身份认证方案
很多团队第一次做 Web3 登录时,都会先写一个“钱包连接 + 签名校验”的最小版本:前端让用户用 MetaMask 签一段字符串,后端拿地址和签名验一下,成功就发 JWT。
这个做法能跑,但离“可上线”通常还有一段距离。
我自己早期做钱包登录时,就踩过几个很典型的坑:
- 没有 nonce,结果签名可以被重放
- 签名文案不规范,不同钱包兼容性不好
- 只校验地址,不校验 domain / chainId / expiration time
- 前端切链后,后端还按旧链逻辑验签
- 把“连接钱包”误当成“完成登录”
这篇文章我们就从工程实战角度,完整做一遍基于 SIWE(Sign-In with Ethereum) 的登录方案。目标不是停留在概念,而是做出一个可运行、可扩展、可上线加固的版本。
背景与问题
为什么“连接钱包”不等于“登录”?
连接钱包(Connect Wallet)只能说明:
- 浏览器里有某个钱包插件或钱包 App
- 用户授权了当前页面读取地址
但它不能证明:
- 当前用户真的持有该地址私钥
- 这次授权是为了登录你的站点
- 这个签名没有被重放
- 这个登录请求没有过期
- 用户是否同意某些会话条款
真正的登录,需要一个标准化的“我是谁、我要登录哪里、这次登录是否有效”的证明过程。
这正是 SIWE(EIP-4361) 要解决的问题。
传统 Web2 登录 vs Web3 钱包登录
Web2 常见流程是:
- 用户输入账号密码 / 手机验证码
- 服务端校验凭证
- 服务端创建会话或 JWT
Web3 钱包登录则变成:
- 前端拿到钱包地址
- 服务端生成带 nonce 的 SIWE Message
- 用户钱包签名
- 服务端验签并建立会话
核心变化在于:身份凭证不再是密码,而是私钥签名能力。
核心原理
SIWE 是什么?
SIWE(Sign-In with Ethereum)是基于 EIP-4361 的登录消息格式标准。
它定义了一个结构化文本,里面通常包含:
- domain:登录站点域名
- address:用户钱包地址
- statement:本次登录说明
- uri:请求来源 URI
- version:协议版本
- chainId:链 ID
- nonce:一次性随机数
- issuedAt:签发时间
- expirationTime:过期时间(可选)
- resources:附加资源(可选)
相比“随便签一句话”,SIWE 的价值在于:让登录消息可读、可解析、可审计、可校验。
登录链路总览
sequenceDiagram
participant U as 用户
participant F as 前端 DApp
participant W as 钱包
participant B as 后端服务
participant S as Session/JWT
U->>F: 点击“钱包登录”
F->>W: 请求钱包地址
W-->>F: 返回 address
F->>B: 请求 nonce / siwe message
B-->>F: 返回 nonce 或完整 SIWE Message
F->>W: personal_sign / signMessage
W-->>F: 返回 signature
F->>B: 提交 message + signature
B->>B: 解析并验签,校验 nonce/domain/chainId/时间
B->>S: 创建会话/JWT
B-->>F: 登录成功
关键安全点
1. Nonce 防重放
如果没有 nonce,攻击者拿到用户曾经签过的登录消息,就可能重复使用。
所以每次登录都必须生成一次性 nonce,并在服务端消费掉。
2. Domain 绑定
签名消息里必须带上你的业务域名,比如 app.example.com。
这样用户签名时,签的是“登录这个站点”,而不是一段可被任意站点复用的文本。
3. 时间边界
建议至少校验:
issuedAtexpirationTime(如果使用)- nonce 过期时间
否则就会出现“昨天的签名今天还有效”的问题。
4. 链 ID 一致性
如果你的业务依赖特定链,比如 Mainnet 或 Base,就要校验 chainId。
不要默认“只要是 EVM 地址都一样”。
前置知识与环境准备
本文示例采用:
- 前端:React + Vite
- 后端:Node.js + Express
- 钱包交互:
ethers - SIWE 解析与校验:
siwe
目录结构
siwe-demo/
backend/
server.js
package.json
frontend/
src/App.jsx
package.json
安装依赖
后端
mkdir backend && cd backend
npm init -y
npm install express cors cookie-parser jsonwebtoken siwe ethers
前端
npm create vite@latest frontend -- --template react
cd frontend
npm install ethers siwe
实战代码(可运行)
下面我会给出一个最小但完整的登录示例:
后端负责生成 nonce、验证签名并签发 JWT;前端负责连接钱包、构造 SIWE Message、发起签名。
第一步:后端实现 nonce 与验签接口
创建 backend/server.js:
const express = require("express");
const cors = require("cors");
const cookieParser = require("cookie-parser");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const { SiweMessage, generateNonce } = require("siwe");
const app = express();
const PORT = 3001;
const JWT_SECRET = "replace-this-in-production";
app.use(express.json());
app.use(cookieParser());
app.use(
cors({
origin: "http://localhost:5173",
credentials: true,
})
);
// 用内存存 nonce,仅用于演示
// 生产环境建议放 Redis,并设置 TTL
const nonceStore = new Map();
/**
* 生成 nonce
*/
app.get("/auth/nonce", (req, res) => {
const nonce = generateNonce();
const nonceId = crypto.randomUUID();
nonceStore.set(nonceId, {
nonce,
createdAt: Date.now(),
used: false,
});
res.json({
nonceId,
nonce,
});
});
/**
* 验证 SIWE 签名并签发 JWT
*/
app.post("/auth/verify", async (req, res) => {
try {
const { message, signature, nonceId } = req.body;
if (!message || !signature || !nonceId) {
return res.status(400).json({ error: "缺少必要参数" });
}
const nonceRecord = nonceStore.get(nonceId);
if (!nonceRecord) {
return res.status(400).json({ error: "nonce 不存在或已过期" });
}
if (nonceRecord.used) {
return res.status(400).json({ error: "nonce 已被使用" });
}
// 5 分钟有效期
if (Date.now() - nonceRecord.createdAt > 5 * 60 * 1000) {
nonceStore.delete(nonceId);
return res.status(400).json({ error: "nonce 已过期" });
}
const siweMessage = new SiweMessage(message);
const result = await siweMessage.verify({
signature,
nonce: nonceRecord.nonce,
domain: "localhost:5173",
});
if (!result.success) {
return res.status(401).json({ error: "签名验证失败" });
}
nonceRecord.used = true;
const token = jwt.sign(
{
sub: siweMessage.address,
address: siweMessage.address,
chainId: siweMessage.chainId,
},
JWT_SECRET,
{ expiresIn: "1h" }
);
res.json({
ok: true,
token,
address: siweMessage.address,
chainId: siweMessage.chainId,
});
} catch (err) {
console.error(err);
res.status(500).json({
error: "服务端验证异常",
detail: err.message,
});
}
});
/**
* 示例:读取当前登录态
*/
app.get("/me", (req, res) => {
try {
const authHeader = req.headers.authorization || "";
const token = authHeader.replace("Bearer ", "");
if (!token) {
return res.status(401).json({ error: "未登录" });
}
const payload = jwt.verify(token, JWT_SECRET);
res.json({
address: payload.address,
chainId: payload.chainId,
});
} catch (err) {
res.status(401).json({ error: "token 无效或已过期" });
}
});
app.listen(PORT, () => {
console.log(`Backend listening on http://localhost:${PORT}`);
});
启动后端
node server.js
第二步:前端发起 SIWE 登录
创建 frontend/src/App.jsx:
import { useState } from "react";
import { BrowserProvider } from "ethers";
import { SiweMessage } from "siwe";
const BACKEND_URL = "http://localhost:3001";
export default function App() {
const [address, setAddress] = useState("");
const [token, setToken] = useState("");
const [profile, setProfile] = useState(null);
const [loading, setLoading] = useState(false);
const login = async () => {
try {
setLoading(true);
if (!window.ethereum) {
alert("请先安装 MetaMask 或其他 EVM 钱包");
return;
}
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const walletAddress = await signer.getAddress();
const network = await provider.getNetwork();
setAddress(walletAddress);
// 1. 从后端获取 nonce
const nonceResp = await fetch(`${BACKEND_URL}/auth/nonce`);
const nonceData = await nonceResp.json();
// 2. 构造 SIWE message
const message = new SiweMessage({
domain: window.location.host,
address: walletAddress,
statement: "Sign in with Ethereum to the app.",
uri: window.location.origin,
version: "1",
chainId: Number(network.chainId),
nonce: nonceData.nonce,
issuedAt: new Date().toISOString(),
});
const messageText = message.prepareMessage();
// 3. 钱包签名
const signature = await signer.signMessage(messageText);
// 4. 发给后端验签
const verifyResp = await fetch(`${BACKEND_URL}/auth/verify`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: messageText,
signature,
nonceId: nonceData.nonceId,
}),
});
const verifyData = await verifyResp.json();
if (!verifyResp.ok) {
throw new Error(verifyData.error || "登录失败");
}
setToken(verifyData.token);
alert(`登录成功:${verifyData.address}`);
} catch (err) {
console.error(err);
alert(err.message || "登录出错");
} finally {
setLoading(false);
}
};
const fetchProfile = async () => {
try {
const resp = await fetch(`${BACKEND_URL}/me`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || "获取用户信息失败");
}
setProfile(data);
} catch (err) {
console.error(err);
alert(err.message);
}
};
return (
<div style={{ padding: 24, fontFamily: "sans-serif" }}>
<h1>SIWE Demo</h1>
<button onClick={login} disabled={loading}>
{loading ? "登录中..." : "使用以太坊钱包登录"}
</button>
{address && <p>当前钱包地址:{address}</p>}
{token && (
<>
<p>JWT 已获取(已省略展示)</p>
<button onClick={fetchProfile}>获取当前用户信息</button>
</>
)}
{profile && (
<pre
style={{
background: "#f5f5f5",
padding: 12,
borderRadius: 8,
}}
>
{JSON.stringify(profile, null, 2)}
</pre>
)}
</div>
);
}
启动前端:
npm run dev
访问 http://localhost:5173,点击按钮后就能完成一次完整的 SIWE 登录。
第三步:理解这段代码到底做了什么
很多人代码能跑,但对“为什么要这样设计”还不够踏实。这里我把流程拆开解释一下。
前端职责
前端只负责三件事:
- 连接钱包,拿到地址和链信息
- 获取后端下发的 nonce
- 让用户签名,并把签名提交后端
后端职责
后端是安全边界,必须负责:
- 生成并保存 nonce
- 验证 SIWE 消息与签名
- 校验 domain、nonce、过期时间、链 ID 等约束
- 创建业务会话(JWT / Session)
为什么不要只在前端验签?
因为前端环境不可信。
用户浏览器里的代码可被篡改,前端验签结果不能直接作为认证依据。
真正决定“你是否登录成功”的逻辑必须在服务端。
登录状态流转图
stateDiagram-v2
[*] --> 未连接钱包
未连接钱包 --> 已连接钱包: connect
已连接钱包 --> 待签名: 获取 nonce 并生成 SIWE Message
待签名 --> 已登录: 用户签名 + 服务端验签成功
待签名 --> 已连接钱包: 用户拒签/验签失败
已登录 --> 已过期: JWT 过期/服务端会话失效
已过期 --> 待签名: 重新发起登录
第四步:把它升级成更像生产环境的版本
上面的版本适合学习,但生产环境还要补几件事。
1. nonce 放 Redis,不要只放内存
内存 Map 有几个问题:
- 服务重启后 nonce 丢失
- 多实例部署无法共享
- 无法方便做 TTL 管理
更合理的做法是:
- key:
siwe:nonce:{nonceId} - value:nonce + createdAt + used 状态
- TTL:5 分钟
2. JWT Secret 放环境变量
不要把密钥写死在代码里。
应该用 .env:
JWT_SECRET=your-super-secret
FRONTEND_ORIGIN=http://localhost:5173
SIWE_DOMAIN=localhost:5173
3. 增加 expirationTime
前端构造 message 时可加入过期时间:
const message = new SiweMessage({
domain: window.location.host,
address: walletAddress,
statement: "Sign in with Ethereum to the app.",
uri: window.location.origin,
version: "1",
chainId: Number(network.chainId),
nonce: nonceData.nonce,
issuedAt: new Date().toISOString(),
expirationTime: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
});
后端可以额外检查是否过期。
4. 引入 HttpOnly Cookie 会更稳
如果你担心前端存 JWT 的 XSS 风险,建议改成:
- 后端验签成功后写入
HttpOnly + Secure + SameSiteCookie - 前端不直接接触 token
- 业务接口依赖 cookie 自动附带
这个方案对 Web 应用通常更稳。
一张结构关系图:谁负责什么
classDiagram
class Frontend {
+connectWallet()
+requestNonce()
+buildSiweMessage()
+signMessage()
+submitSignature()
}
class Wallet {
+getAddress()
+signMessage()
}
class Backend {
+generateNonce()
+verifySignature()
+validateDomain()
+validateChainId()
+issueSession()
}
class NonceStore {
+save()
+get()
+markUsed()
+expire()
}
Frontend --> Wallet
Frontend --> Backend
Backend --> NonceStore
逐步验证清单
如果你想确认自己的实现是“真的对了”,我建议按下面清单逐项验证。
基础验证
- 钱包能正常连接
- 前端能拿到地址
- 后端能正确下发 nonce
- 钱包能弹出签名框
- 服务端验签成功并返回 JWT / Session
安全验证
- 同一个 nonce 第二次提交会失败
- 过期 nonce 提交会失败
- 修改 message 任意字段后,验签失败
- domain 改成别的域名时,验签失败
- 错链登录时,能按策略拒绝或提示切链
体验验证
- 用户拒签时,前端能明确提示
- 钱包未安装时,有清晰引导
- 网络切换后,前端状态能刷新
- 登录成功后,会话能被后续接口识别
常见坑与排查
这一节我尽量写得“接地气”一点,因为 SIWE 项目里,很多问题不是原理错,而是细节差一点。
坑 1:前后端 domain 不一致
现象
服务端报验签失败,或提示 domain mismatch。
原因
前端构造 message 时用了:
domain: window.location.host
而后端验证时写死的是:
domain: "localhost:3000"
如果你前端实际跑在 localhost:5173,那就对不上。
排查建议
打印这三个值:
window.location.host- message 原文中的 domain
- 后端 verify 的 domain
确保完全一致,包含端口。
坑 2:链 ID 类型不一致
现象
消息能签,后端也能解析,但业务层判断链时异常。
原因
有的地方拿到的是 bigint,有的地方转成了 number 或字符串。
例如 ethers v6 的 network.chainId 可能需要手动转。
建议
前端构造 SIWE Message 时显式处理:
chainId: Number(network.chainId)
后端也统一用数字或字符串,不要混着来。
坑 3:把“签名任意文本”当成 SIWE
现象
你让用户签了:
login to my app
然后后端用 ethers.verifyMessage 去恢复地址。
问题
这只是“签名校验”,不是完整的 SIWE 登录。你缺了:
- nonce
- domain
- 标准格式
- 时间字段
- 可解析结构
建议
如果是正式的 Web3 认证,尽量直接上 siwe 标准,不要自己拼协议。
坑 4:nonce 用完不销毁
现象
攻击者可以重复提交同一组 message + signature。
原因
后端只检查 nonce 是否存在,没有在成功后标记已消费。
建议
验签成功后立即:
- 标记 used
- 或直接删除 nonce
- 最好同时记录操作日志
坑 5:用户换号了,前端还用旧登录态
现象
用户在钱包里切换地址后,页面还显示之前账号已登录。
原因
钱包地址变化了,但业务会话没同步失效。
建议
监听钱包事件:
window.ethereum.on("accountsChanged", (accounts) => {
console.log("accountsChanged", accounts);
// 清理本地登录态,要求重新登录
});
window.ethereum.on("chainChanged", (chainId) => {
console.log("chainChanged", chainId);
// 提示刷新或重新拉取状态
});
这个坑我当时就踩过:测试时觉得一切正常,结果用户一切地址,页面直接“串号”。
安全/性能最佳实践
安全最佳实践
1. 永远在服务端验签
前端可以做辅助检查,但认证结论必须由后端给出。
2. nonce 必须一次性、短时有效
建议:
- 长度足够随机
- TTL 5 分钟左右
- 使用后立即失效
3. 校验完整字段,不只验地址
至少校验:
- signature
- nonce
- domain
- issuedAt
- expirationTime(若存在)
- chainId
- address 格式
4. 登录消息文案要清晰
用户在钱包弹窗里会看到签名内容。
建议 statement 直白一点,例如:
Sign in to Example App.
No blockchain transaction or gas fee is required.
这样能减少用户误解,也能降低“签了什么都不知道”的风险。
5. 优先使用会话 Cookie
如果是 Web 应用,我更推荐:
HttpOnlySecureSameSite=Lax或Strict
能有效降低 token 被前端脚本读走的风险。
6. 记录审计日志
至少记录:
- address
- chainId
- domain
- nonceId
- 登录时间
- IP / User-Agent(按隐私合规要求处理)
一旦出问题,排查会轻松很多。
性能最佳实践
1. nonce 存储走 Redis
低延迟、支持 TTL、适合多实例。
2. 避免不必要的链上请求
SIWE 登录本身不需要链上交易,也不需要频繁查 RPC。
大部分登录操作都可以纯离线验签完成。
3. JWT 载荷尽量轻
不要把用户一大堆资料塞到 JWT 里。
建议只放:
subaddresschainId- 必要的角色标记
更多资料从数据库查。
4. 针对验签接口做限流
/auth/nonce 和 /auth/verify 都建议加:
- IP 限流
- 地址维度限流
- 异常重试策略
这能减少恶意刷接口。
一个更接近生产的后端校验思路
下面给一个偏“伪生产”的验签逻辑示意,方便你后续扩展。
async function verifySiweLogin({ message, signature, nonceRecord, expectedDomain }) {
const siweMessage = new SiweMessage(message);
const result = await siweMessage.verify({
signature,
nonce: nonceRecord.nonce,
domain: expectedDomain,
});
if (!result.success) {
throw new Error("SIWE verify failed");
}
if (!siweMessage.chainId) {
throw new Error("Missing chainId");
}
const now = Date.now();
if (siweMessage.expirationTime) {
const expiredAt = new Date(siweMessage.expirationTime).getTime();
if (now > expiredAt) {
throw new Error("SIWE message expired");
}
}
// 根据业务需要限制链
const allowedChains = [1, 11155111];
if (!allowedChains.includes(Number(siweMessage.chainId))) {
throw new Error("Unsupported chain");
}
return {
address: siweMessage.address,
chainId: Number(siweMessage.chainId),
};
}
什么时候 SIWE 不够?
SIWE 很适合解决“钱包登录”问题,但它不是万能身份系统。
SIWE 适合
- DApp 登录
- 钱包地址绑定用户身份
- 无密码登录
- Web3 社区、任务平台、链上数据产品
SIWE 不直接解决
- 复杂权限模型
- 多钱包账户聚合身份
- 社交恢复
- 去中心化可验证凭证(VC)
- 跨链统一 DID 治理
如果你的需求再往上走,可能还要结合:
- ENS
- DID
- Verifiable Credentials
- Account Abstraction
- MPC / Embedded Wallet
所以我的建议是:
先用 SIWE 把登录这层做扎实,再考虑更复杂的身份体系。
总结
如果你只记住一句话,那就是:
Web3 钱包登录的核心,不是“连上钱包”,而是“基于标准消息做一次可验证、可防重放、可建立会话的签名认证”。
回顾一下整套方案:
- 前端连接钱包并获取地址
- 后端生成一次性 nonce
- 前端按 SIWE 标准构造消息
- 用户用钱包签名
- 后端验证签名、domain、nonce、时间与链 ID
- 服务端建立 JWT 或 Session
如果你准备上线,我建议最低做到这几件事:
- 使用标准
siwe库,不要自造协议 - nonce 放 Redis,并设置短 TTL
- 验签后立即消费 nonce
- 校验 domain、chainId、时间边界
- 优先用 HttpOnly Cookie 管理会话
- 监听
accountsChanged/chainChanged,防止前端状态错乱
边界条件也要明确:
- 如果你的 App 是纯前端静态页,没有可信后端,那就很难完成“真正的认证”
- 如果你支持多链,要提前定义清楚哪些链允许登录
- 如果你需要更强的身份表达能力,SIWE 只是起点,不是终点
最后,SIWE 的难点其实不在“代码多复杂”,而在于你是否把消息格式、状态流转、安全边界想清楚。只要这三件事站稳,钱包登录这块就会从“能演示”升级成“能上线”。