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

《Web3 钱包接入实战:基于 EIP-4361 实现 Sign-In with Ethereum 登录系统》

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

Web3 钱包接入实战:基于 EIP-4361 实现 Sign-In with Ethereum 登录系统

在 Web2 世界里,“登录”通常意味着用户名密码、短信验证码,或者 OAuth。到了 Web3,很多团队第一反应是:既然用户有钱包,那我让他签个名,不就算登录了吗?

思路没错,但如果直接“前端发一段固定字符串,用户签名,后端验签”就上线,后面大概率会遇到几个问题:

  • 签名内容不规范,不同钱包兼容性差
  • 容易被重放攻击
  • 域名、链 ID、过期时间没绑定,安全边界模糊
  • 前后端对“谁已经登录”理解不一致
  • 多链、多环境、移动端钱包接入后,流程开始混乱

这也是 EIP-4361:Sign-In with Ethereum(SIWE) 的意义。它不是发明“签名登录”,而是把这件事标准化:什么内容该签、怎么表达上下文、如何绑定域名和时效,尽量减少“每家都造一个半成品协议”的情况。

这篇文章我不只讲“怎么签”,更从一个架构设计的角度,带你搭一套能上线的 SIWE 登录系统:前端连接钱包、后端生成 nonce、服务端验签、建立 session,再到常见坑和安全边界。


背景与问题

为什么不能“随便签一段字符串”?

很多项目早期都是这样的:

  1. 用户连接 MetaMask
  2. 前端让用户签名:Login to xxx
  3. 后端恢复地址,验证成功后发 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

这份消息有两个关键特点:

  1. 人能读懂:钱包弹窗里用户看到的是明确的登录声明
  2. 机器能解析:后端可以按标准字段校验

关键校验点

后端在验证签名时,不应该只做“签名恢复地址成功”这一件事,还要一起检查:

  • 地址是否与 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 中的 domain
  • uri

代码背后的关键点

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:3000127.0.0.1:3000localhost 是不同值
  • 端口也算 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 的 sameSitesecure 配置不对

排查方法:

  • 打开浏览器开发者工具,看响应头里是否真的设置了 cookie
  • 看后续请求是否带上 cookie

坑 6:钱包地址切换后,旧会话还在

这是一个架构上的“边界问题”。

如果用户:

  1. 用地址 A 登录
  2. 登录成功后在钱包里切换到地址 B
  3. 前端页面没刷新

那后端 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 App
  • Authenticate 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 真正接进产品,我建议记住三个核心原则:

  1. 标准化消息,不手写野路子签名文本
  2. 验签只是开始,真正登录要靠 nonce + 会话管理闭环
  3. 业务接口只认应用层登录态,不要每次都重新玩签名

最后给一个最务实的落地建议:

  • 小型项目:SIWE + 服务端 Session + Redis nonce
  • 多服务项目:SIWE + 认证中心 + 短期 JWT/Session 双层设计
  • 安全要求高:增加限流、地址变更重登、审计日志和风控策略

如果你现在正准备把“连接钱包”升级成“正式登录系统”,那 SIWE 基本就是最值得优先采用的方案。它不完美,但足够成熟,而且边界清晰。把这条链路搭对了,后面的 Web3 用户系统才不会越做越乱。


分享到:

上一篇
《分布式架构中基于幂等设计与消息补偿机制的订单系统一致性实战指南》
下一篇
《自动化测试中的接口回归体系设计:基于 Pytest 与 CI/CD 的可维护实践》