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

《Web3 钱包登录实战:用 SIWE(Sign-In with Ethereum)构建安全的去中心化身份认证系统》

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

Web3 钱包登录实战:用 SIWE(Sign-In with Ethereum)构建安全的去中心化身份认证系统

在传统 Web 应用里,登录这件事几乎默认等于“账号 + 密码”,后来演进成“手机号 + 验证码”或者“OAuth 第三方登录”。但到了 Web3 场景,用户并不天然希望再创建一个中心化账号体系——他们已经有钱包地址,也掌握私钥,最自然的想法就是:能不能直接用钱包完成身份认证?

答案是可以,而且现在业内相对标准的方案就是 SIWE(Sign-In with Ethereum)

这篇文章我会从架构视角带你走一遍:为什么要用 SIWE、它到底解决了什么问题、完整登录链路怎么设计、代码怎么写、线上会踩哪些坑,以及如何把它做得更安全、更稳定。


背景与问题

为什么“钱包地址 = 身份”还不够?

很多刚接触 Web3 登录的同学会有一个直觉:
“用户把钱包地址发给后端,不就知道他是谁了吗?”

这其实不成立。因为地址是公开信息,任何人都可以声称“我是这个地址的主人”。真正的问题不是“这个地址是什么”,而是:

你怎么证明你控制了这个地址对应的私钥?

这就是签名登录存在的必要性。

传统登录体系在 Web3 中的几个不适配点

  1. 密码体系不符合用户习惯
    Web3 用户已经习惯用钱包管理身份,不愿再记一套站内密码。

  2. OAuth 依赖中心化身份提供商
    Google、GitHub 登录很好用,但它们不是链上身份,也不天然适用于链上权限模型。

  3. 纯地址登录缺少抗重放能力
    如果没有 nonce、domain、expiration 等约束,历史签名可能被重放。

  4. 前后端很容易各写一套“自定义签名协议”
    这类方案最常见的问题是格式不统一、字段不完整、兼容性差,后期很难维护。

SIWE 解决了什么?

SIWE 本质上是一个标准化的以太坊签名登录协议。它让“钱包签名认证”这件事从“随便拼一段字符串”变成了:

  • 有统一消息格式
  • 有明确字段语义
  • 支持 nonce、防重放
  • 支持 domain / URI / chainId / expiration 等约束
  • 便于前后端和多钱包生态兼容

方案概览与架构设计

从架构上看,SIWE 不是“用户签个名就登录了”这么简单,而是一条完整的认证链路:

  1. 前端请求服务端生成 nonce
  2. 前端构造 SIWE message
  3. 用户在钱包中签名
  4. 前端把 message + signature 发回服务端
  5. 服务端验证签名、校验 nonce、domain、时间窗口等
  6. 验证成功后,服务端签发自己的会话令牌(通常是 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:当前应用 URI
  • version:通常为 1
  • chainId:链 ID
  • nonce:随机数,防重放
  • issuedAt:签发时间
  • expirationTime:可选,过期时间
  • notBefore:可选,生效时间

这些字段不是摆设,它们决定了安全边界。

比如:

  • domain 防止签名被别的站点滥用
  • nonce 防止同一签名被重复提交
  • chainId 帮助你区分用户在哪条链上下文中完成登录
  • expirationTime 限制签名长期有效带来的风险

验证过程本质是什么?

服务端拿到 message + signature 后,会做几类校验:

  1. 密码学校验
    验证这个签名是否真的由 address 对应私钥产生。

  2. 协议字段校验
    核对 domainurichainIdversion 是否符合预期。

  3. 会话状态校验
    确认 nonce 是服务端刚刚发出去的,并且尚未使用。

  4. 时间窗口校验
    校验 issuedAtexpirationTimenotBefore 是否有效。

它和“链上交易签名”有什么不同?

很多人第一次做钱包登录会混淆两类签名:

  • 交易签名:要上链、要消耗 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

优点:

  • 服务端可控,便于失效
  • 适合 Web 应用

缺点:

  • 分布式下要做 session 存储共享

JWT

优点:

  • 无状态,扩展方便
  • 适合前后端分离、多终端 API

缺点:

  • 提前吊销比较麻烦
  • 容易被误用成“长期有效万能票据”

如果你的前端就是浏览器 DApp,我建议优先用 HttpOnly Cookie + 服务端 Session/Redis
这样可以减少 token 在浏览器侧暴露的风险。


常见坑与排查

这部分我自己踩过不少,基本都是“明明签了名,为什么后端说不对”。

1. domain 不一致

现象:

  • 前端构造 message 时 domainlocalhost: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,不要手写常量,除非你明确只支持某一条链。

现象:

  • /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_signeth_signsignMessage 上兼容性存在差异。
SIWE 通常走的是标准消息签名链路,前端尽量使用成熟库封装,而不是手搓 RPC 调用。


安全最佳实践

这一节最关键。SIWE 登录能不能上线,不在于“能不能签”,而在于“边界是否收住”。

1. 一定校验 nonce

这是防重放的第一道门槛。
如果你只验证签名真假,不验证 nonce,那么别人截获历史签名后就可能重放登录。

2. 一定校验 domainuri

这能防止签名被跨站复用。
我见过一些实现只看 addresssignature,这相当于把签名变成“通用令牌”,风险很大。

3. 给签名设置时间窗口

建议使用:

  • issuedAt
  • expirationTime
  • 必要时加 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 是目前非常值得采用的标准方案。它的核心价值不只是“让用户用钱包登录”,而是把这件事做成了一个可验证、可扩展、可维护的认证架构

你可以记住这几个落地要点:

  1. 钱包签名只负责证明地址控制权
  2. 登录成功后必须签发你自己的会话
  3. nonce、domain、时间窗口一定要校验
  4. 多实例部署时,把 nonce/session 放到共享存储
  5. 高风险操作不要只依赖普通登录态

如果你现在正准备上线一个带后端的 DApp,我的建议很直接:

  • 不要自创签名协议,优先采用 SIWE
  • 不要让前端单独承担认证逻辑
  • 不要把签名当长期 token 使用
  • 先把登录链路跑通,再补齐会话、安全、审计

这样做,系统不会一开始就“看起来很 Web3,实际上很脆弱”。相反,它会更像一个真正能承受生产流量和安全要求的身份认证系统。


分享到:

上一篇
《区块链节点数据同步与状态存储优化实战:从全节点部署到性能调优》
下一篇
《从提示工程到工作流编排:中级开发者构建可落地 AI Agent 的实战指南》