跳转到内容
123xiao | 无名键客

《Web3 钱包登录实战:基于 EIP-4361(Sign-In with Ethereum)构建安全可扩展的身份认证体系》

字数: 0 阅读时长: 1 分钟

Web3 钱包登录实战:基于 EIP-4361(Sign-In with Ethereum)构建安全可扩展的身份认证体系

很多团队第一次做 Web3 登录时,都会下意识地走一条“看起来能跑”的路:

  1. 前端让用户连接钱包
  2. 让钱包签个字符串
  3. 后端 recover address
  4. 通过就算登录成功

这条路确实能跑,但通常也埋着一堆隐患:签名内容不规范、重放攻击、域名校验缺失、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 定义了一种标准消息格式,大意是:

  • 谁在请求登录(domainuri
  • 谁在登录(address
  • 这次登录的随机挑战值(nonce
  • 什么时候发起、什么时候失效(issuedAtexpirationTime
  • 作用在哪条链上(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)如果前端启动后报依赖问题,安装 siweethers

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();

现象

/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

不要只验签名对不对,要验“上下文”对不对。

推荐校验项:

  • domain
  • nonce
  • uri
  • version
  • chainId
  • issuedAt
  • expirationTime(如果有)
  • 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 件事

  1. 用 SIWE 标准消息,不要自定义随意字符串
  2. nonce 一次性、短时有效,最好放 Redis
  3. 严格校验 domain / chainId / uri / expiration
  4. 签名成功后建立自己的 session,不把签名当永久凭证
  5. 内部用户 ID 与钱包地址解耦
  6. 提前考虑合约钱包与多实例部署

如果你现在正在把“连接钱包”升级成“真正可用的认证系统”,那本文这套方案基本就是一个很稳的起点:先用标准把地基打牢,再根据业务往绑定账户、权限系统、风控策略上迭代。这样你后面做用户体系时,会轻松很多。


分享到:

上一篇
《集群架构实战:从单体服务到高可用多节点部署的设计与演进路径》
下一篇
《从原型到生产:中级开发者构建企业级 AI 问答系统的检索增强生成(RAG)实战路径》