Web3 钱包登录实战:基于 EIP-4361(Sign-In with Ethereum)构建安全可扩展的身份认证体系
很多团队第一次做 Web3 登录时,都会下意识地走一条“看起来能跑”的路:
- 前端让用户连接钱包
- 让钱包签个字符串
- 后端
recover address - 通过就算登录成功
这条路确实能跑,但通常也埋着一堆隐患:签名内容不规范、重放攻击、域名校验缺失、Nonce 管理混乱、会话与钱包地址绑定不严,最后从“能登录”变成“能被绕过”。
这篇文章我想带你用 EIP-4361(Sign-In with Ethereum,简称 SIWE),搭一套更标准、更安全、也更容易扩展的 Web3 身份认证体系。我们不只讲概念,还会直接给出一套 前后端可运行示例,你可以本地跑起来,再按自己的业务改造。
背景与问题
为什么“钱包地址 = 身份”还不够
在 Web2 里,我们习惯用用户名、手机号、邮箱作为身份标识;到了 Web3,用户更常说“我用钱包登录”。但这里有个容易混淆的点:
- 钱包地址 是一种可验证标识
- 登录态 则是一次有上下文、有时效、有挑战值的认证结果
如果只让用户签一个固定字符串,比如:
Login to my app
那攻击者一旦拿到签名结果,就可能在别的时间、别的地方重复使用,也就是典型的重放攻击。
传统“随便签一下”的几个问题
常见问题包括:
- 签名消息无标准格式:前后端各写各的,后面难以维护
- 缺少 nonce:签名可重复利用
- 缺少 domain / uri 绑定:容易被钓鱼站复用
- 没有 expiration time:登录凭证无边界
- 会话体系不清晰:签名成功后如何发 session / JWT 不统一
- 多钱包、多链支持困难:系统扩展性差
EIP-4361 的价值就在这里:它给出了一套标准的“以太坊登录消息格式”和验证语义,让钱包登录从“野路子”变成“有协议可依”。
前置知识与环境准备
你需要知道什么
建议你至少具备这些基础:
- 会一点 Node.js / Express
- 知道以太坊地址、签名、私钥、公钥的基本概念
- 用过 MetaMask 或其他 EVM 钱包
- 对 Cookie / Session / JWT 至少了解一种
本文技术栈
我这里选一套尽量简单、又足够接近生产的组合:
- 前端:
Vite + Vanilla JS - 后端:
Node.js + Express - 以太坊工具:
ethers - SIWE 解析与校验:
siwe - Session:
express-session
安装依赖
先建两个目录:
mkdir siwe-demo
cd siwe-demo
mkdir server client
后端初始化:
cd server
npm init -y
npm install express cors express-session siwe ethers dotenv
前端初始化:
cd ../client
npm create vite@latest . -- --template vanilla
npm install
核心原理
先别急着写代码,先把这套认证链路想清楚。
EIP-4361 的本质
EIP-4361 定义了一种标准消息格式,大意是:
- 谁在请求登录(
domain、uri) - 谁在登录(
address) - 这次登录的随机挑战值(
nonce) - 什么时候发起、什么时候失效(
issuedAt、expirationTime) - 作用在哪条链上(
chainId) - 用户声明什么资源访问意图(可选
resources)
用户用钱包对这段消息签名,服务端验证签名和字段合法性后,再建立自己的会话。
认证流程图
flowchart TD
A[前端请求 nonce] --> B[后端生成并保存 nonce]
B --> C[前端构造 SIWE Message]
C --> D[钱包签名]
D --> E[前端提交 message + signature]
E --> F[后端校验签名/nonce/domain/时效]
F -->|成功| G[创建 session/JWT]
F -->|失败| H[返回认证失败]
时序图
sequenceDiagram
participant U as User
participant W as Wallet
participant F as Frontend
participant S as Server
U->>F: 点击“使用钱包登录”
F->>S: GET /nonce
S-->>F: 返回 nonce
F->>W: 请求签名 SIWE Message
W-->>F: 返回 signature
F->>S: POST /verify {message, signature}
S->>S: 校验 nonce / domain / address / signature
S-->>F: 设置 session,返回登录成功
F-->>U: 展示登录态
关键字段别忽略
我把几个最关键的字段单拎出来说一下:
- domain:当前登录站点域名,防止签名消息被别站复用
- nonce:一次性挑战值,必须随机且短时有效
- chainId:明确用户在哪条链上签名
- issuedAt / expirationTime:定义签名有效时间窗口
- statement:给用户看的说明文字,要清楚,不要误导
- uri:当前登录请求的资源标识
会话状态图
stateDiagram-v2
[*] --> Anonymous
Anonymous --> NonceIssued: 请求 nonce
NonceIssued --> Signed: 用户完成签名
Signed --> Authenticated: 服务端验证通过
Signed --> Anonymous: 验证失败/nonce失效
Authenticated --> Anonymous: 登出/Session过期
实战代码(可运行)
下面我们直接做一套最小可运行版本。
后端实现
1)创建 server/index.js
import express from "express";
import cors from "cors";
import session from "express-session";
import { SiweMessage, generateNonce } from "siwe";
const app = express();
const PORT = 3001;
app.use(express.json());
app.use(
cors({
origin: "http://localhost:5173",
credentials: true,
})
);
app.use(
session({
name: "siwe.sid",
secret: "replace-this-with-a-strong-secret",
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: false,
sameSite: "lax",
maxAge: 1000 * 60 * 60 * 2,
},
})
);
// 获取 nonce
app.get("/nonce", (req, res) => {
const nonce = generateNonce();
req.session.nonce = nonce;
res.json({ nonce });
});
// 校验签名并登录
app.post("/verify", async (req, res) => {
try {
const { message, signature } = req.body;
if (!message || !signature) {
return res.status(400).json({ ok: false, error: "Missing message or signature" });
}
const siweMessage = new SiweMessage(message);
const result = await siweMessage.verify({
signature,
nonce: req.session.nonce,
domain: "localhost:5173",
});
// 防止 nonce 被重复使用
req.session.nonce = null;
req.session.siwe = {
address: result.data.address,
chainId: result.data.chainId,
};
return res.json({
ok: true,
address: result.data.address,
chainId: result.data.chainId,
});
} catch (err) {
return res.status(401).json({
ok: false,
error: err?.message || "Verification failed",
});
}
});
// 查询当前登录态
app.get("/me", (req, res) => {
if (!req.session.siwe) {
return res.status(401).json({ ok: false, authenticated: false });
}
return res.json({
ok: true,
authenticated: true,
user: req.session.siwe,
});
});
// 登出
app.post("/logout", (req, res) => {
req.session.destroy(() => {
res.clearCookie("siwe.sid");
res.json({ ok: true });
});
});
app.listen(PORT, () => {
console.log(`SIWE server running at http://localhost:${PORT}`);
});
2)修改 server/package.json
把 type 和启动脚本加上:
{
"name": "server",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"scripts": {
"dev": "node index.js"
}
}
3)启动后端
cd server
npm run dev
前端实现
1)替换 client/src/main.js
import "./style.css";
import { ethers } from "ethers";
import { SiweMessage } from "siwe";
const app = document.querySelector("#app");
app.innerHTML = `
<div>
<h1>SIWE 登录示例</h1>
<button id="connectBtn">使用钱包登录</button>
<button id="meBtn">查看当前登录态</button>
<button id="logoutBtn">退出登录</button>
<pre id="output"></pre>
</div>
`;
const output = document.querySelector("#output");
function print(data) {
output.textContent = typeof data === "string" ? data : JSON.stringify(data, null, 2);
}
async function getNonce() {
const res = await fetch("http://localhost:3001/nonce", {
credentials: "include",
});
return res.json();
}
async function verify(message, signature) {
const res = await fetch("http://localhost:3001/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ message, signature }),
});
return res.json();
}
async function getMe() {
const res = await fetch("http://localhost:3001/me", {
credentials: "include",
});
return res.json();
}
async function logout() {
const res = await fetch("http://localhost:3001/logout", {
method: "POST",
credentials: "include",
});
return res.json();
}
document.querySelector("#connectBtn").addEventListener("click", async () => {
try {
if (!window.ethereum) {
throw new Error("未检测到钱包,请先安装 MetaMask");
}
const provider = new ethers.BrowserProvider(window.ethereum);
await provider.send("eth_requestAccounts", []);
const signer = await provider.getSigner();
const address = await signer.getAddress();
const network = await provider.getNetwork();
const { nonce } = await getNonce();
const message = new SiweMessage({
domain: window.location.host,
address,
statement: "使用 Ethereum 钱包登录当前应用。",
uri: window.location.origin,
version: "1",
chainId: Number(network.chainId),
nonce,
issuedAt: new Date().toISOString(),
});
const messageString = message.prepareMessage();
const signature = await signer.signMessage(messageString);
const result = await verify(messageString, signature);
print(result);
} catch (err) {
print(err.message || "登录失败");
}
});
document.querySelector("#meBtn").addEventListener("click", async () => {
try {
const data = await getMe();
print(data);
} catch (err) {
print(err.message || "获取失败");
}
});
document.querySelector("#logoutBtn").addEventListener("click", async () => {
try {
const data = await logout();
print(data);
} catch (err) {
print(err.message || "退出失败");
}
});
2)如果前端启动后报依赖问题,安装 siwe 与 ethers
cd client
npm install siwe ethers
3)启动前端
npm run dev
浏览器打开 http://localhost:5173,你就能看到一个最小 SIWE 登录页面。
逐步验证清单
我建议你别一上来就改业务代码,先按下面顺序验证。这样出了问题比较好定位。
第一步:确认钱包注入成功
浏览器控制台执行:
window.ethereum
如果是 undefined,说明钱包扩展没装,或者当前浏览器环境不对。
第二步:确认后端能正常发 nonce
直接访问:
http://localhost:3001/nonce
应返回类似:
{
"nonce": "Jx8mK2QpL7nB"
}
第三步:确认签名消息内容合理
前端拿到 messageString 后,可以先 console.log 一下,确认包含这些内容:
localhost:5173 wants you to sign in...- 你的钱包地址
- nonce
- chainId
- issuedAt
第四步:确认 session 被带上
浏览器开发者工具里看 Network:
/nonce请求有响应/verify请求时带了 Cookie/me能读取登录态
如果你没看到 Cookie,大概率是 CORS 或 credentials: "include" 没配好。
常见坑与排查
这部分是最值钱的,因为 SIWE 真正花时间的地方,往往不在“写代码”,而在“为什么明明对了却验不过”。
1)domain 校验失败
现象
后端报类似:
Domain does not match provided domain for verification
原因
前端构造 SIWE Message 时用了:
domain: window.location.host
而后端验证写死的是:
domain: "localhost:5173"
如果你前端端口变了、用了内网域名、反向代理改了 Host,这里就会不一致。
建议
- 开发环境允许从配置读取 domain
- 生产环境严格校验真实业务域名
- 不要为了省事把 domain 校验去掉
2)nonce 重复使用导致失败
现象
第一次登录成功,第二次复用旧签名失败。
这是正常的
nonce 本来就应该一次性使用。
如果旧签名还能重复登录,那才是真的有问题。
建议
后端在验证成功后立刻销毁 nonce:
req.session.nonce = null;
如果你用 Redis,也应在消费成功后删除。
3)钱包地址大小写不一致
以太坊地址可能出现校验和大小写形式,比如:
0xAbC...
有些同学会在数据库里全转小写,有些保留原样。这本身不是问题,问题在于你系统内要统一。
建议
- 认证层按标准地址校验
- 存储层统一转小写便于索引
- 展示层可保留 checksum address
例如:
const normalizedAddress = result.data.address.toLowerCase();
4)本地环境 Cookie 不生效
现象
/verify 明明成功了,但 /me 总是未登录。
常见原因
- 前端
fetch没写credentials: "include" - 后端 CORS 未设置
credentials: true - Cookie 的
sameSite/secure配置不合适
本地开发推荐
cookie: {
httpOnly: true,
secure: false,
sameSite: "lax",
}
生产环境如果前后端跨站更复杂,通常要上 HTTPS,并合理配置 sameSite: "none" 和 secure: true。
5)链 ID 不一致
现象
用户钱包切到了别的链,签名照样做了,但你的业务其实只支持某条链。
建议
登录前就先检查链:
const expectedChainId = 1;
if (Number(network.chainId) !== expectedChainId) {
throw new Error("请先切换到主网再登录");
}
后端也不要只信前端,验证后再判断:
if (result.data.chainId !== 1) {
return res.status(400).json({ ok: false, error: "Unsupported chain" });
}
安全最佳实践
很多人以为 SIWE 只要“验签成功”就万事大吉,其实离生产可用还差一段距离。
1)永远不要把签名当永久登录凭证
签名只说明“某个地址在某个时刻同意了这段消息”。
真正的业务登录态,仍然应该由你的服务端控制,比如:
- Session
- 短期 JWT + 刷新机制
- 绑定设备与风控状态
也就是说,签名是认证起点,不是整个身份系统本身。
2)nonce 必须随机、短时、一次性
最理想的实现是:
- 高强度随机值
- 存在 Redis 等服务端存储中
- 5 分钟左右过期
- 验证成功即销毁
如果你的系统是多实例部署,不要把 nonce 只存在进程内存里,不然一上负载均衡就容易出问题。
3)校验 domain、uri、chainId、expirationTime
不要只验签名对不对,要验“上下文”对不对。
推荐校验项:
domainnonceuriversionchainIdissuedAtexpirationTime(如果有)notBefore(如果有)
4)为登录消息加可读 statement
用户签名时最怕看到一段莫名其妙的英文或者十六进制内容。
清楚的 statement 能显著降低误签风险。
例如:
使用 Ethereum 钱包登录当前应用。不会发起链上交易,也不会消耗 Gas。
这是一个非常实用的小细节,我自己做产品时特别看重,因为它直接影响转化率和信任感。
5)区分“登录签名”和“业务签名”
这点很重要:
- 登录签名:用于认证
- 订单签名 / 挂单签名 / 授权签名:用于业务动作
不要混用。
更不要拿一次登录签名去证明“用户同意了某笔交易”——语义完全不一样。
6)限制签名频率,防止滥用
接口最好加限流:
/nonce:按 IP、钱包地址、会话限流/verify:按 IP、失败次数限流
尤其是开放给公网后,会有人批量撞接口,虽然验签本身不算特别重,但配合会话和日志也会给系统带来压力。
性能与可扩展设计
当你从 Demo 走向生产时,通常会遇到两个问题:怎么扛并发,怎么做扩展。
推荐的演进方向
单机 Demo 阶段
- nonce 存 session
- 登录态也存 session
- 够快,够简单
小规模生产阶段
- nonce 放 Redis
- session 放 Redis
- API 多实例部署
- 反向代理层做 TLS 终止
平台化阶段
- SIWE 认证服务独立成 Auth Service
- 统一输出用户主身份 ID
- 钱包地址作为身份凭证之一
- 支持地址绑定、解绑、主钱包切换、多人组织账户等能力
一个更接近生产的组件关系图
classDiagram
class Frontend {
+requestNonce()
+signMessage()
+submitSignature()
}
class AuthService {
+generateNonce()
+verifySiwe()
+issueSession()
}
class Redis {
+storeNonce()
+storeSession()
}
class UserService {
+findOrCreateUserByWallet()
+bindWallet()
}
Frontend --> AuthService
AuthService --> Redis
AuthService --> UserService
一个很实用的身份映射建议
不要把“钱包地址”直接当成你系统内唯一用户 ID。更稳妥的做法是:
- 系统内部维护
user_id - 一个
user_id可绑定多个钱包地址 - 标记主地址、签名地址、风控状态
- 支持未来绑定邮箱、社交账号、MPC 钱包、AA 钱包
这样你后面扩展账户体系时,不会被“地址即用户”绑死。
进阶改造:接入 Redis 管理 nonce
如果你已经准备上线,建议把 nonce 从 session 中拆出来。下面给个简化示意。
安装 Redis 客户端
npm install ioredis
示例代码
import Redis from "ioredis";
import { generateNonce, SiweMessage } from "siwe";
const redis = new Redis("redis://127.0.0.1:6379");
app.get("/nonce", async (req, res) => {
const nonce = generateNonce();
const key = `siwe:nonce:${req.sessionID}`;
await redis.set(key, nonce, "EX", 300);
res.json({ nonce });
});
app.post("/verify", async (req, res) => {
try {
const { message, signature } = req.body;
const siweMessage = new SiweMessage(message);
const key = `siwe:nonce:${req.sessionID}`;
const nonce = await redis.get(key);
if (!nonce) {
return res.status(400).json({ ok: false, error: "Nonce expired" });
}
const result = await siweMessage.verify({
signature,
nonce,
domain: "localhost:5173",
});
await redis.del(key);
req.session.siwe = {
address: result.data.address.toLowerCase(),
chainId: result.data.chainId,
};
res.json({ ok: true, user: req.session.siwe });
} catch (err) {
res.status(401).json({ ok: false, error: err.message });
}
});
这个版本更适合多实例部署,因为 nonce 不再依赖某一台应用服务器的内存。
边界条件:哪些场景要额外设计
SIWE 解决的是“基于以太坊地址的签名登录”,但并不是所有身份问题都能靠它一次解决。
1)智能合约钱包
如果用户使用的是合约钱包,底层签名验证可能涉及 EIP-1271。
很多库已经做了一层兼容,但你仍然要确认:
- 你的验证库是否支持
- 目标钱包是否按标准实现
- 你使用的链上 RPC 是否稳定
2)多链钱包登录
如果你产品支持多条 EVM 链:
- 登录层可以统一使用 SIWE
- 但链能力、资产读取、业务权限仍要按链区分
- 不要因为“都是 EVM”就假设业务完全一致
3)移动端钱包与 WalletConnect
如果不是浏览器插件钱包,而是移动端唤起钱包签名,你要额外考虑:
- 深链唤起
- 会话恢复
- 回跳页面状态
- 签名超时与取消处理
这些问题不属于 EIP-4361 本身,但在真实项目里很常见。
总结
如果你只记住一件事,我希望是这句:
Web3 钱包登录不是“让用户随便签一下”,而是“围绕标准消息、一次性挑战和服务端会话建立可信认证”。
用 EIP-4361 做 SIWE,有几个非常现实的好处:
- 登录消息标准化,前后端更容易协作
- 认证语义更清晰,减少重放和域名复用风险
- 更容易扩展到多钱包、多链、多身份凭证体系
- 从 Demo 到生产的演进路径比较顺
最后给你一份可执行建议清单,适合直接落地:
建议优先做的 6 件事
- 用 SIWE 标准消息,不要自定义随意字符串
- nonce 一次性、短时有效,最好放 Redis
- 严格校验 domain / chainId / uri / expiration
- 签名成功后建立自己的 session,不把签名当永久凭证
- 内部用户 ID 与钱包地址解耦
- 提前考虑合约钱包与多实例部署
如果你现在正在把“连接钱包”升级成“真正可用的认证系统”,那本文这套方案基本就是一个很稳的起点:先用标准把地基打牢,再根据业务往绑定账户、权限系统、风控策略上迭代。这样你后面做用户体系时,会轻松很多。