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

《Web3 中级实战:从零搭建基于以太坊的钱包登录与链上签名验证系统》

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

Web3 中级实战:从零搭建基于以太坊的钱包登录与链上签名验证系统

很多人第一次做 Web3 登录,直觉会是:前端连上 MetaMask,用户签个名,后端 recover 一下地址,不就完了?

这条路能跑通 Demo,但一到真实业务环境,很快就会冒出一堆问题:

  • 签名消息怎么设计,才能防重放?
  • 为什么有时 personal_sign 验证通过,有时却不通过?
  • 只做后端验签够不够?什么时候需要链上验证?
  • 钱包登录和传统 Session / JWT 怎么结合?
  • 多链、多钱包、SIWE(Sign-In with Ethereum)要不要一步到位?

这篇文章我会从架构设计的角度,带你搭一个可运行的最小系统:
包含前端钱包连接、服务端 nonce 挑战、消息签名、后端验签、JWT 会话签发,以及一个链上验签合约用于需要上链校验的场景。

重点不是“拼代码”,而是把这套方案的边界讲清楚:什么应该放链下,什么必须放链上,什么时候别把系统做复杂。


背景与问题

为什么钱包登录和传统账号密码不一样

传统 Web2 登录,平台掌握身份凭证:

  • 用户名/密码
  • 短信验证码
  • OAuth 第三方授权

而 Web3 登录的核心变化是:

  • 用户身份由钱包地址代表
  • 用户私钥由钱包客户端保管
  • 平台无法也不应该接触私钥
  • 身份证明通过“对一段消息签名”完成

也就是说,平台不再“验证你知道密码”,而是“验证你是否控制该地址对应的私钥”。

真实业务中的几个关键问题

1. 只认地址,不认链上状态,会不会太弱?

如果你的需求只是“登录网站”,链下验签通常足够。
但如果你的需求是:

  • 只有持有某 NFT 的用户才能操作
  • 某个关键动作必须在合约里验证签名
  • 签名结果将作为链上执行依据

那就必须考虑链上验签或链上状态校验。

2. 签名消息如果设计不好,会被重放

比如你让用户签:

Login to MyApp

这就很危险。因为:

  • 没有 nonce
  • 没有 domain
  • 没有过期时间
  • 没有链 ID
  • 没有用途说明

攻击者一旦拿到签名,未来可能重复提交。

3. EOA 和合约钱包不是一回事

普通钱包地址(EOA)可以用 ecrecover 验证。
但合约钱包(如 Safe)并没有私钥,不能简单 recover。
这类地址一般要走 EIP-1271

如果你的系统面向更广泛用户,只支持 EOA,会埋坑。


方案概览

我建议把系统拆成两层:

  1. 链下身份层:完成登录、会话管理、基础权限控制
  2. 链上验证层:在需要上链可信执行时,对签名做合约内验证

这样做的好处是:

  • 大部分登录请求走后端,成本低、延迟小
  • 只有关键动作才走链上,避免把所有事情都做成昂贵的 on-chain 流程
  • 架构上更容易演进到 SIWE、EIP-1271、多链支持

架构设计

整体流程图

flowchart TD
    A[前端连接钱包] --> B[请求后端生成 nonce]
    B --> C[后端保存 nonce 与过期时间]
    C --> D[前端拼接登录消息]
    D --> E[钱包对消息签名]
    E --> F[前端提交 address + message + signature]
    F --> G[后端验签 recoverAddress]
    G --> H{地址匹配且 nonce 有效?}
    H -- 是 --> I[销毁 nonce]
    I --> J[签发 JWT / Session]
    H -- 否 --> K[返回 401]

链下与链上职责划分

flowchart LR
    subgraph OffChain[链下服务]
      A1[Nonce 挑战]
      A2[消息组装]
      A3[EOA 验签]
      A4[JWT/Session]
      A5[风控与审计]
    end

    subgraph OnChain[链上合约]
      B1[签名校验]
      B2[关键动作授权]
      B3[NFT / Token 状态读取]
      B4[不可抵赖记录]
    end

    A3 --> B1
    A4 --> B2
    A5 --> B4

时序图

sequenceDiagram
    participant U as 用户
    participant W as 钱包
    participant F as 前端
    participant S as 后端
    participant C as 验签合约

    U->>F: 点击钱包登录
    F->>W: 请求连接地址
    W-->>F: 返回 address
    F->>S: GET /auth/nonce?address=...
    S-->>F: 返回 nonce
    F->>W: signMessage(message)
    W-->>F: signature
    F->>S: POST /auth/verify
    S->>S: recoverAddress 验签
    S-->>F: JWT

    Note over F,C: 关键动作场景
    F->>C: submit(signature, messageHash)
    C->>C: ecrecover / EIP-1271
    C-->>F: 验证结果

核心原理

1. 钱包登录本质是“签名挑战”

最常见模式是 Challenge-Response:

  1. 用户提供钱包地址
  2. 服务端生成一次性 nonce
  3. 前端把业务上下文和 nonce 组装成消息
  4. 钱包签名
  5. 服务端恢复签名者地址并比对
  6. 验证通过后签发会话

关键点是:签名的不是密码,而是一段带上下文的声明文本


2. 为什么必须有 nonce

nonce 的作用是防重放。

如果没有 nonce,攻击者拿到一份合法签名,就能无限次复用。
而有了 nonce 后:

  • 每次登录都会变化
  • 服务端验证通过后立即作废
  • 即使签名泄露,也不能再次使用

一个合格的登录消息,至少应包含:

  • domain / app name
  • wallet address
  • nonce
  • issuedAt
  • expirationTime
  • chainId
  • statement / purpose

3. personal_sign / eth_signTypedData_v4 的区别

personal_sign

优点:

  • 兼容性好
  • 前后端实现简单

缺点:

  • 消息展示体验一般
  • 结构化字段不明显
  • 更容易因字符串编码出错

eth_signTypedData_v4(EIP-712)

优点:

  • 结构化数据签名
  • 钱包展示更友好
  • 域隔离更清晰
  • 更适合严肃业务

缺点:

  • 前后端编码要严格一致
  • 不同钱包兼容细节更多

这篇文章为了可运行和容易理解,登录流程先用 personal_sign
后面我会补充什么时候应该升级到 EIP-712。


4. 链上验签为什么存在

后端验签解决的是“平台相信你确实控制这个地址”。

链上验签解决的是“合约也能独立确认这份签名是合法的”。

典型场景:

  • 用户离线签名授权,Relayer 代为提交交易
  • Permit / Voucher / Mint 白名单
  • 订单撮合、支付授权、空投领取
  • 需要合约层可验证的业务凭证

如果只是网站登录,链上验签通常不是必需。
如果动作最终要落到智能合约执行,那链上验签往往就是必要能力。


方案对比与取舍分析

方案一:纯链下钱包登录

做法:前端签名,后端验签,签发 JWT

适合

  • DApp 官网
  • 社区后台
  • 用户中心
  • 内容平台

优点

  • 实现简单
  • 成本低
  • 性能高

缺点

  • 合约本身不感知登录结果
  • 无法直接作为链上授权凭证

方案二:链下登录 + 链上关键动作验签

做法

  • 登录走链下
  • 高价值动作提交链上时,再进行合约验签

适合

  • Mint 白名单
  • 交易授权
  • NFT 权益核销
  • 代签/中继场景

优点

  • 兼顾性能和可信性
  • 架构分层清晰
  • 容易演进

缺点

  • 设计复杂度更高
  • 要维护链下和链上的消息规范一致性

我个人最推荐这个方案。绝大多数中级项目,在这里是投入产出比最好的。


方案三:所有验证都放链上

优点

  • 最强去信任
  • 验证逻辑公开透明

缺点

  • 成本高
  • 延迟大
  • 用户体验差
  • 对登录类需求通常过度设计

除非你做的是协议级业务,否则不建议把“网站登录”也强行做成全链上。


实战代码(可运行)

下面给出一个最小可运行系统:

  • 前端:React + ethers v6
  • 后端:Node.js + Express + ethers v6 + JWT
  • 合约:Solidity,支持 EOA 的链上验签

目录结构示意:

web3-login-demo/
  backend/
    package.json
    server.js
  frontend/
    App.jsx
  contracts/
    SignatureVerifier.sol

一、后端:生成 nonce、验签并签发 JWT

1. 安装依赖

mkdir backend && cd backend
npm init -y
npm install express cors jsonwebtoken ethers

2. 编写服务端

// backend/server.js
const express = require("express");
const cors = require("cors");
const jwt = require("jsonwebtoken");
const { ethers } = require("ethers");
const crypto = require("crypto");

const app = express();
app.use(cors());
app.use(express.json());

const PORT = 3001;
const JWT_SECRET = "replace-with-a-strong-secret";

// 演示用内存存储,生产环境请换 Redis / DB
const nonceStore = new Map();

function generateNonce() {
  return crypto.randomBytes(16).toString("hex");
}

function buildLoginMessage({ domain, address, nonce, chainId, issuedAt, expirationTime }) {
  return [
    `${domain} wants you to sign in with your Ethereum account:`,
    address,
    ``,
    `Sign in to the app.`,
    ``,
    `URI: https://${domain}`,
    `Version: 1`,
    `Chain ID: ${chainId}`,
    `Nonce: ${nonce}`,
    `Issued At: ${issuedAt}`,
    `Expiration Time: ${expirationTime}`,
  ].join("\n");
}

app.get("/auth/nonce", (req, res) => {
  const { address } = req.query;

  if (!address || !ethers.isAddress(address)) {
    return res.status(400).json({ error: "Invalid address" });
  }

  const nonce = generateNonce();
  const now = new Date();
  const expiration = new Date(now.getTime() + 5 * 60 * 1000);

  nonceStore.set(address.toLowerCase(), {
    nonce,
    issuedAt: now.toISOString(),
    expirationTime: expiration.toISOString(),
  });

  res.json({
    domain: "localhost",
    address,
    chainId: 1,
    nonce,
    issuedAt: now.toISOString(),
    expirationTime: expiration.toISOString(),
    message: buildLoginMessage({
      domain: "localhost",
      address,
      nonce,
      chainId: 1,
      issuedAt: now.toISOString(),
      expirationTime: expiration.toISOString(),
    }),
  });
});

app.post("/auth/verify", async (req, res) => {
  try {
    const { address, message, signature } = req.body;

    if (!address || !message || !signature) {
      return res.status(400).json({ error: "Missing params" });
    }

    if (!ethers.isAddress(address)) {
      return res.status(400).json({ error: "Invalid address" });
    }

    const record = nonceStore.get(address.toLowerCase());
    if (!record) {
      return res.status(400).json({ error: "Nonce not found" });
    }

    const now = new Date();
    if (now > new Date(record.expirationTime)) {
      nonceStore.delete(address.toLowerCase());
      return res.status(400).json({ error: "Nonce expired" });
    }

    const expectedMessage = buildLoginMessage({
      domain: "localhost",
      address,
      nonce: record.nonce,
      chainId: 1,
      issuedAt: record.issuedAt,
      expirationTime: record.expirationTime,
    });

    if (message !== expectedMessage) {
      return res.status(400).json({ error: "Message mismatch" });
    }

    const recovered = ethers.verifyMessage(message, signature);

    if (recovered.toLowerCase() !== address.toLowerCase()) {
      return res.status(401).json({ error: "Signature invalid" });
    }

    // 验证成功后立即销毁 nonce,防重放
    nonceStore.delete(address.toLowerCase());

    const token = jwt.sign(
      { sub: address.toLowerCase(), wallet: address.toLowerCase() },
      JWT_SECRET,
      { expiresIn: "2h" }
    );

    res.json({
      ok: true,
      token,
      address: address.toLowerCase(),
    });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "Internal error" });
  }
});

app.get("/me", (req, res) => {
  const auth = req.headers.authorization || "";
  const token = auth.replace(/^Bearer\s+/i, "");

  try {
    const payload = jwt.verify(token, JWT_SECRET);
    res.json({ user: payload });
  } catch (err) {
    res.status(401).json({ error: "Unauthorized" });
  }
});

app.listen(PORT, () => {
  console.log(`Backend running at http://localhost:${PORT}`);
});

3. 启动后端

node server.js

二、前端:连接钱包并完成登录

这里用一个最小 React 组件演示。你也可以很容易改成 Next.js。

1. 安装 ethers

npm install ethers

2. 前端代码

// frontend/App.jsx
import React, { useState } from "react";
import { BrowserProvider } from "ethers";

export default function App() {
  const [address, setAddress] = useState("");
  const [token, setToken] = useState("");
  const [profile, setProfile] = useState(null);
  const [loading, setLoading] = useState(false);

  async function connectWallet() {
    if (!window.ethereum) {
      alert("请先安装 MetaMask");
      return;
    }

    const provider = new BrowserProvider(window.ethereum);
    const signer = await provider.getSigner();
    const addr = await signer.getAddress();
    setAddress(addr);
  }

  async function login() {
    try {
      setLoading(true);

      if (!window.ethereum) {
        alert("请先安装 MetaMask");
        return;
      }

      const provider = new BrowserProvider(window.ethereum);
      const signer = await provider.getSigner();
      const addr = await signer.getAddress();

      const nonceResp = await fetch(`http://localhost:3001/auth/nonce?address=${addr}`);
      const nonceData = await nonceResp.json();

      if (!nonceResp.ok) {
        throw new Error(nonceData.error || "获取 nonce 失败");
      }

      const message = nonceData.message;
      const signature = await signer.signMessage(message);

      const verifyResp = await fetch("http://localhost:3001/auth/verify", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          address: addr,
          message,
          signature,
        }),
      });

      const verifyData = await verifyResp.json();

      if (!verifyResp.ok) {
        throw new Error(verifyData.error || "验签失败");
      }

      setAddress(verifyData.address);
      setToken(verifyData.token);
      alert("登录成功");
    } catch (err) {
      console.error(err);
      alert(err.message);
    } finally {
      setLoading(false);
    }
  }

  async function fetchMe() {
    const resp = await fetch("http://localhost:3001/me", {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });
    const data = await resp.json();
    setProfile(data);
  }

  return (
    <div style={{ padding: 24, fontFamily: "sans-serif" }}>
      <h1>Web3 钱包登录 Demo</h1>

      <button onClick={connectWallet}>连接钱包</button>
      <button onClick={login} disabled={loading} style={{ marginLeft: 12 }}>
        {loading ? "登录中..." : "钱包登录"}
      </button>
      <button onClick={fetchMe} disabled={!token} style={{ marginLeft: 12 }}>
        获取当前用户
      </button>

      <div style={{ marginTop: 16 }}>
        <div><b>地址:</b>{address || "-"}</div>
        <div><b>Token:</b>{token || "-"}</div>
        <pre>{profile ? JSON.stringify(profile, null, 2) : ""}</pre>
      </div>
    </div>
  );
}

三、链上验签合约

如果你的某个关键动作要在合约里确认“这份签名确实来自某地址”,可以用下面这个最小合约。

Solidity 合约

// contracts/SignatureVerifier.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SignatureVerifier {
    function getEthSignedMessageHash(bytes32 messageHash) public pure returns (bytes32) {
        return keccak256(
            abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
        );
    }

    function recoverSigner(bytes32 ethSignedMessageHash, bytes memory signature)
        public
        pure
        returns (address)
    {
        (bytes32 r, bytes32 s, uint8 v) = splitSignature(signature);
        return ecrecover(ethSignedMessageHash, v, r, s);
    }

    function verify(
        address signer,
        bytes32 messageHash,
        bytes memory signature
    ) public pure returns (bool) {
        bytes32 ethSignedMessageHash = getEthSignedMessageHash(messageHash);
        return recoverSigner(ethSignedMessageHash, signature) == signer;
    }

    function splitSignature(bytes memory sig)
        internal
        pure
        returns (bytes32 r, bytes32 s, uint8 v)
    {
        require(sig.length == 65, "invalid signature length");

        assembly {
            r := mload(add(sig, 32))
            s := mload(add(sig, 64))
            v := byte(0, mload(add(sig, 96)))
        }
    }
}

说明

这个合约验证的是以太坊签名标准前缀下的签名,也就是常见的 personal_sign / signMessage 风格。

注意这里的输入是:

  • signer: 预期签名地址
  • messageHash: 原始消息哈希
  • signature: 用户签名

四、链上验签调用示例

前端或脚本端,你要确保链下 hash 方式和链上完全一致。

import { ethers } from "ethers";

async function demoVerifyOnChain(contract, signer) {
  const rawMessage = "mint whitelist for user";
  const messageHash = ethers.keccak256(ethers.toUtf8Bytes(rawMessage));
  const signature = await signer.signMessage(ethers.getBytes(messageHash));
  const signerAddress = await signer.getAddress();

  const ok = await contract.verify(signerAddress, messageHash, signature);
  console.log("on-chain verify result:", ok);
}

这里很容易踩坑,我当时第一次写的时候就把两件事混在一起了:

  • 原始字符串签名
  • 32 字节哈希签名

这两种最终的前缀处理并不一样。
如果你链上写的是 "\x19Ethereum Signed Message:\n32",那链下就要对 bytes32 对应内容来签,而不是直接签原始长字符串。


容量估算与架构扩展

对于中级项目,很多人上来就担心“这个系统扛不扛得住”。其实钱包登录的大头不在验签,而在状态管理和防刷

单次登录的成本拆分

链下登录大致包含:

  • 1 次获取 nonce
  • 1 次钱包签名
  • 1 次后端验签
  • 1 次 JWT 签发

后端 verifyMessage 本身非常快,真正会成为瓶颈的通常是:

  • Redis / DB 的 nonce 读写
  • 风控系统
  • 跨域与网关层
  • 前端钱包交互等待时间

一个实用的容量判断

如果你的用户规模在:

  • 日活 1 万以内:单体服务 + Redis 足够
  • 日活 10 万级:建议拆分认证服务,nonce 放 Redis,日志进消息队列
  • 更大规模:再考虑多区域部署、限流网关、审计分流

也就是说,别过早优化验签函数本身,先把 nonce 和风控设计好。


常见坑与排查

这一节我尽量讲得“像真会遇到的坑”,而不是只列名词。

1. 前后端消息内容不一致

这是最常见的问题。

例如前端签的是:

localhost wants you to sign in with your Ethereum account:
0xabc...

Sign in to the app.

URI: https://localhost
Version: 1
Chain ID: 1
Nonce: xxx
Issued At: xxx
Expiration Time: xxx

但后端 rebuild message 时:

  • 多了一个空格
  • 少了一个换行
  • 时间格式不一样
  • address 大小写被改了

都会导致验签失败。

排查方式

  • 把前端 message 原文打印出来
  • 把后端 expectedMessage 原文打印出来
  • 用字符串 diff 工具逐字符对比

我一般建议:签什么就传什么,但后端仍要自己重建并比较
不要只相信前端传来的 message。


2. 使用了错误的签名方法

你以为自己签的是 personal_sign,实际钱包 SDK 用的是 typed data。
或者你链上按 signMessage 的前缀验证,链下却签了原始 typed data。

判断方法

看前端调用的是:

  • signer.signMessage(...) → 多数是 personal_sign
  • eth_signTypedData_v4 → EIP-712

两者验签逻辑不能混用。


3. 链上验签时 hash 处理不一致

这个坑特别常见。

错误写法思路

链下:

await signer.signMessage("hello")

链上:

keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash))

这不一定匹配,因为链下签的是原始字符串 "hello",前缀中的长度是 5,不是 32

正确思路

要么:

  • 链下直接签原始字符串
  • 链上按原始字符串长度拼接前缀

要么:

  • 链下先把内容 hash 成 bytes32
  • 再对 bytes32 进行 signMessage(getBytes(hash))
  • 链上用 \n32 版本验证

4. nonce 没有及时失效

如果你在验签成功后没有删除 nonce,就可能导致重放。
如果你只按 address 存一个 nonce,而用户同时开多个标签页登录,也可能互相覆盖。

建议

nonce 记录至少包含:

  • address
  • nonce
  • issuedAt
  • expirationTime
  • used 标志或删除策略
  • requestId / sessionId(更稳妥)

5. chainId 写了但没真正校验

有些系统在消息里写了 Chain ID: 1,但后端根本不检查。
这样字段就只是“摆设”。

如果你的业务依赖链环境,比如:

  • 只能在主网登录某权限
  • 必须基于某测试网签名

那就需要前端明确获取当前链,并在后端检查消息中的链 ID 是否符合预期。


6. 合约钱包登录失败

如果你只用 ethers.verifyMessage 恢复地址,那么:

  • EOA 可以
  • 合约钱包不一定可以

因为合约钱包通常遵循 EIP-1271
这时后端要做的是:

  1. 先判断地址是否是合约地址
  2. 如果是 EOA,用 verifyMessage
  3. 如果是合约地址,调用目标合约的 isValidSignature

安全最佳实践

1. 登录消息一定要具备上下文

至少包括:

  • 域名
  • 地址
  • nonce
  • 发布时间
  • 过期时间
  • 链 ID
  • 用途说明

不要让用户签“无意义短句”。


2. nonce 必须一次性、短时有效

推荐:

  • 5 分钟有效
  • 验证成功立即删除
  • 失败次数过多时强制刷新 nonce

生产环境建议使用 Redis,并设置 TTL。


3. JWT 只是会话凭证,不是链上身份本身

钱包签名通过后签发 JWT 很常见,但要记住:

  • JWT 是你系统内部会话
  • 钱包地址才是外部身份根
  • JWT 过期、吊销、续签策略要单独设计

如果是后台管理系统,还应该增加:

  • 刷新令牌
  • 黑名单机制
  • 多端登录控制

4. 能用 HTTPS 就绝不要裸奔

如果登录消息、JWT、接口都走明文 HTTP,中间人攻击风险会非常高。
本地开发可以 localhost,正式环境必须全站 HTTPS。


5. 关键业务优先考虑 EIP-712

对于下面这些场景,我建议直接上 EIP-712:

  • 白名单凭证
  • 订单授权
  • NFT Mint Voucher
  • 交易签名确认
  • 需要明确结构化字段的授权消息

因为它更不容易出现“我到底签了什么”的歧义。


6. 做好审计日志

至少记录:

  • address
  • nonce
  • IP / UA
  • issuedAt / verifyAt
  • 验签结果
  • token 签发记录
  • 失败原因

这在排查风控问题时特别重要。
很多“验签偶发失败”,最后其实是前端代理层改了请求,或者钱包扩展在移动端行为不一致。


性能最佳实践

1. nonce 用 Redis,不要长期放数据库热表

登录认证是高频短状态,Redis 更合适:

  • 原子删除
  • TTL 方便
  • 读写快

数据库更适合存审计日志,而不是热 nonce。


2. JWT 验证应无状态化

/me 这类接口优先使用 JWT 本地验签,减少每次查库。
如果需要强制登出,再结合黑名单或版本号机制。


3. 把“链上验证”限制在高价值动作

不要每次页面打开都去合约验签。
链上调用昂贵且慢,适合:

  • 最终授权
  • 发放权益
  • 提现、购买、铸造等关键动作

4. 前端要缓存地址状态,但别缓存旧 nonce

地址可以缓存,nonce 不要缓存复用。
每次登录重新获取新 nonce。


进阶演进路线

当你把本文这套方案跑通后,下一步通常是下面几个方向。

1. 升级到 SIWE

本文的 message 格式已经很接近 SIWE(EIP-4361)思路。
如果你准备对接更多钱包和标准化生态,可以直接采用 SIWE 标准字段和库。

2. 引入 EIP-712

当签名消息变得结构化、涉及明确业务授权时,EIP-712 会更稳。

3. 支持 EIP-1271

如果你的用户会使用 Safe、多签、AA 钱包,这是迟早要补的能力。

4. 与链上资产权限结合

例如:

  • 登录后校验是否持有某 NFT
  • 根据 ERC-20 持仓授予角色
  • 根据 ENS / SBT / POAP 做用户分层

这一步才是真正把“钱包登录”变成“链上身份系统”。


总结

从架构上看,钱包登录最稳妥的做法不是“把一切放链上”,而是:

  • 登录认证走链下
  • 关键授权走链上
  • 消息规范要可重建、可审计、防重放

如果你现在要落地一个中级 Web3 项目,我给你的可执行建议是:

  1. 先用 personal_sign + nonce + 后端 verifyMessage + JWT 跑通主流程
  2. nonce 放 Redis,5 分钟过期,成功即销毁
  3. 登录消息带上 domain、chainId、issuedAt、expirationTime、purpose
  4. 关键合约操作单独设计链上验签,不要复用“登录消息”
  5. 计划支持合约钱包时,补上 EIP-1271
  6. 当业务授权字段变复杂,升级到 EIP-712 / SIWE

最后给一个边界判断:

  • 只是网站登录:链下验签足够
  • 涉及资产或合约授权:必须设计链上验证
  • 涉及多签/AA 钱包:不能只靠 ecrecover

把这几条边界想清楚,你的 Web3 登录系统就不会停留在 Demo,而是能真正服务生产环境。


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》