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

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

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

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 常见流程是:

  1. 用户输入账号密码 / 手机验证码
  2. 服务端校验凭证
  3. 服务端创建会话或 JWT

Web3 钱包登录则变成:

  1. 前端拿到钱包地址
  2. 服务端生成带 nonce 的 SIWE Message
  3. 用户钱包签名
  4. 服务端验签并建立会话

核心变化在于:身份凭证不再是密码,而是私钥签名能力


核心原理

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. 时间边界

建议至少校验:

  • issuedAt
  • expirationTime(如果使用)
  • 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 登录。


第三步:理解这段代码到底做了什么

很多人代码能跑,但对“为什么要这样设计”还不够踏实。这里我把流程拆开解释一下。

前端职责

前端只负责三件事:

  1. 连接钱包,拿到地址和链信息
  2. 获取后端下发的 nonce
  3. 让用户签名,并把签名提交后端

后端职责

后端是安全边界,必须负责:

  1. 生成并保存 nonce
  2. 验证 SIWE 消息与签名
  3. 校验 domain、nonce、过期时间、链 ID 等约束
  4. 创建业务会话(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(),
});

后端可以额外检查是否过期。

如果你担心前端存 JWT 的 XSS 风险,建议改成:

  • 后端验签成功后写入 HttpOnly + Secure + SameSite Cookie
  • 前端不直接接触 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.

这样能减少用户误解,也能降低“签了什么都不知道”的风险。

如果是 Web 应用,我更推荐:

  • HttpOnly
  • Secure
  • SameSite=LaxStrict

能有效降低 token 被前端脚本读走的风险。

6. 记录审计日志

至少记录:

  • address
  • chainId
  • domain
  • nonceId
  • 登录时间
  • IP / User-Agent(按隐私合规要求处理)

一旦出问题,排查会轻松很多。


性能最佳实践

1. nonce 存储走 Redis

低延迟、支持 TTL、适合多实例。

2. 避免不必要的链上请求

SIWE 登录本身不需要链上交易,也不需要频繁查 RPC。
大部分登录操作都可以纯离线验签完成。

3. JWT 载荷尽量轻

不要把用户一大堆资料塞到 JWT 里。
建议只放:

  • sub
  • address
  • chainId
  • 必要的角色标记

更多资料从数据库查。

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 钱包登录的核心,不是“连上钱包”,而是“基于标准消息做一次可验证、可防重放、可建立会话的签名认证”。

回顾一下整套方案:

  1. 前端连接钱包并获取地址
  2. 后端生成一次性 nonce
  3. 前端按 SIWE 标准构造消息
  4. 用户用钱包签名
  5. 后端验证签名、domain、nonce、时间与链 ID
  6. 服务端建立 JWT 或 Session

如果你准备上线,我建议最低做到这几件事:

  • 使用标准 siwe 库,不要自造协议
  • nonce 放 Redis,并设置短 TTL
  • 验签后立即消费 nonce
  • 校验 domain、chainId、时间边界
  • 优先用 HttpOnly Cookie 管理会话
  • 监听 accountsChanged / chainChanged,防止前端状态错乱

边界条件也要明确:

  • 如果你的 App 是纯前端静态页,没有可信后端,那就很难完成“真正的认证”
  • 如果你支持多链,要提前定义清楚哪些链允许登录
  • 如果你需要更强的身份表达能力,SIWE 只是起点,不是终点

最后,SIWE 的难点其实不在“代码多复杂”,而在于你是否把消息格式、状态流转、安全边界想清楚。只要这三件事站稳,钱包登录这块就会从“能演示”升级成“能上线”。


分享到:

上一篇
《区块链节点数据索引实战:基于 The Graph 构建可查询的链上业务数据服务》
下一篇
《微服务架构中的分布式事务实战:基于 Saga 模式的设计、落地与避坑-98》