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

《Web3 中级实战:基于 EIP-712 与钱包签名实现安全的链上登录与授权流程》

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

Web3 中级实战:基于 EIP-712 与钱包签名实现安全的链上登录与授权流程

很多团队第一次做 Web3 登录,都是从“让用户签个消息”开始的:前端调钱包、用户点确认、后端验签、签发 session。这个思路没错,但真正上线后,问题很快就会冒出来:

  • 文本签名内容不结构化,用户看不懂自己签了什么
  • 不同钱包对 personal_sign 支持表现不一致
  • 容易遗漏 nonce、过期时间、域隔离,导致重放风险
  • 登录和授权混在一起,越做越乱
  • 前端能跑,后端验签总失败,排查成本很高

这篇文章我换一个更偏“落地工程”的角度,带你做一套可运行的 EIP-712 登录与授权流程
不仅讲“怎么签”,还讲为什么这么设计后端怎么验证怎么避免把登录系统做成半个安全事故


背景与问题

在 Web2 里,登录通常靠用户名密码、短信验证码或 OAuth。到了 Web3,用户身份的起点变成了钱包地址的控制权
也就是说:谁能对某个地址对应的私钥完成签名,谁就能证明自己“是这个地址的持有者”

但“证明地址所有权”只是第一步。真正的业务系统还需要解决这些问题:

  1. 登录:让后端知道“这个请求确实来自该钱包用户”
  2. 授权:让系统知道“用户授权了某个动作”
  3. 防重放:防止旧签名被别人拿去重复使用
  4. 可读性:让用户在钱包里看到清晰、可审计的签名内容
  5. 跨环境隔离:测试网、主网、不同站点之间不能串签

如果你还在直接签一段字符串,比如:

Login to my dapp: 0x123...

那它通常有几个明显短板:

  • 没有结构化字段,钱包展示不友好
  • 没有强约束域名、链 ID、版本
  • 容易遗漏 nonce、deadline
  • 后端对签名意图的语义理解弱

这就是 EIP-712 要解决的核心问题。


前置知识与环境准备

本文用一套最常见的技术栈:

  • 前端:React + ethers
  • 后端:Node.js + Express + ethers
  • 钱包:MetaMask 或兼容 EIP-712 的钱包
  • 目标:实现两类签名
    • 登录签名:建立服务端会话
    • 授权签名:对某个业务动作进行一次性授权

安装依赖:

mkdir web3-eip712-auth && cd web3-eip712-auth
npm init -y
npm install express cors cookie-parser jsonwebtoken ethers

如果你要做前端示例:

npm install react react-dom ethers

核心原理

1. EIP-712 在解决什么

EIP-712 的本质,是让“要签的数据”变成结构化、可读、可验证的数据对象,而不是一坨自由文本。

它把签名对象拆成三部分:

  • domain:签名域,限定这个签名属于哪个应用、哪个链、哪个版本
  • types:结构体定义
  • message:实际签名数据

这样钱包在展示时,会更像“你正在签署一份表单”,而不是“你正在签一段看不懂的字符串”。

2. 为什么它更适合登录与授权

因为登录/授权这类场景,本来就天然是“有字段、有语义”的:

  • wallet
  • nonce
  • issuedAt
  • expirationTime
  • statement
  • uri
  • action
  • resourceId

这些字段非常适合结构化表达。

3. 登录与授权要分开建模

这是我很建议中级开发者尽早建立的习惯:
“登录”是身份确认,“授权”是业务同意。不要混成一个签名。

比如:

  • 登录签名:LoginMessage
  • 授权签名:ActionAuthorization

这样后端逻辑会清楚很多,审计也容易。


一图看懂完整流程

flowchart TD
    A[前端请求登录挑战] --> B[后端生成 nonce 和 EIP-712 message]
    B --> C[前端调用钱包 signTypedData]
    C --> D[用户确认签名]
    D --> E[前端提交 address + signature + message]
    E --> F[后端验证 EIP-712 签名]
    F --> G[校验 nonce/域/过期时间]
    G --> H[签发 session 或 JWT]

登录流程的结构设计

登录消息模型

我们先定义一个登录结构体:

LoginMessage(
  wallet: address,
  nonce: string,
  issuedAt: string,
  expirationTime: string,
  statement: string,
  uri: string
)

这里每个字段都不是摆设:

  • wallet:预期登录的钱包地址
  • nonce:一次性随机挑战,防重放
  • issuedAt:签发时间
  • expirationTime:过期时间,限制签名生命周期
  • statement:给用户看的登录说明
  • uri:当前站点来源,帮助做域绑定

EIP-712 Domain 设计

推荐这样设计:

const domain = {
  name: "MyDapp Auth",
  version: "1",
  chainId: 1,
  verifyingContract: "0x0000000000000000000000000000000000000000"
};

对于纯链下登录,verifyingContract 常见有两种策略:

  1. 用零地址占位
  2. 用你的认证服务约定的固定地址标识

重点不是它必须真有合约,而是前后端必须一致
我自己更倾向于:如果这是纯链下认证协议,就固定约定一个值并写入文档,不要随手乱填。


时序图:登录挑战与验签

sequenceDiagram
    participant U as 用户
    participant F as 前端 DApp
    participant W as 钱包
    participant B as 后端服务

    U->>F: 点击钱包登录
    F->>B: GET /auth/challenge?address=0x...
    B->>B: 生成 nonce 与过期时间
    B-->>F: 返回 domain/types/message
    F->>W: signTypedData(domain, types, message)
    W-->>F: signature
    F->>B: POST /auth/verify
    B->>B: recoverAddress + nonce 校验 + 时效校验
    B-->>F: session/JWT
    F-->>U: 登录成功

实战代码(可运行)

下面我给出一套最小可跑通的后端和前端示例。
为了便于演示,nonce 先放内存里。生产环境请放 Redis 或数据库。


后端:Express + ethers 实现挑战生成与验签

新建 server.js

const express = require("express");
const cors = require("cors");
const cookieParser = require("cookie-parser");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const { ethers } = require("ethers");

const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(
  cors({
    origin: "http://localhost:5173",
    credentials: true
  })
);

const PORT = 3001;
const JWT_SECRET = "replace-this-in-production";

// 演示用内存存储:address => challenge
const challengeStore = new Map();

const DOMAIN = {
  name: "MyDapp Auth",
  version: "1",
  chainId: 1,
  verifyingContract: "0x0000000000000000000000000000000000000000"
};

const LOGIN_TYPES = {
  LoginMessage: [
    { name: "wallet", type: "address" },
    { name: "nonce", type: "string" },
    { name: "issuedAt", type: "string" },
    { name: "expirationTime", type: "string" },
    { name: "statement", type: "string" },
    { name: "uri", type: "string" }
  ]
};

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

app.get("/auth/challenge", (req, res) => {
  try {
    const address = String(req.query.address || "").toLowerCase();
    const chainId = Number(req.query.chainId || 1);
    const uri = String(req.query.uri || "http://localhost:5173");

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

    const nonce = generateNonce();
    const issuedAt = new Date().toISOString();
    const expirationTime = new Date(Date.now() + 5 * 60 * 1000).toISOString();

    const message = {
      wallet: ethers.getAddress(address),
      nonce,
      issuedAt,
      expirationTime,
      statement: "Sign this message to login securely.",
      uri
    };

    const domain = {
      ...DOMAIN,
      chainId
    };

    challengeStore.set(address, {
      nonce,
      message,
      domain,
      createdAt: Date.now(),
      used: false
    });

    return res.json({
      domain,
      types: LOGIN_TYPES,
      primaryType: "LoginMessage",
      message
    });
  } catch (err) {
    return res.status(500).json({ error: err.message });
  }
});

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

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

    const normalizedAddress = String(address).toLowerCase();
    const saved = challengeStore.get(normalizedAddress);

    if (!saved) {
      return res.status(400).json({ error: "Challenge not found" });
    }

    if (saved.used) {
      return res.status(400).json({ error: "Challenge already used" });
    }

    if (saved.nonce !== message.nonce) {
      return res.status(400).json({ error: "Nonce mismatch" });
    }

    if (saved.domain.chainId !== domain.chainId) {
      return res.status(400).json({ error: "ChainId mismatch" });
    }

    if (saved.domain.name !== domain.name || saved.domain.version !== domain.version) {
      return res.status(400).json({ error: "Domain mismatch" });
    }

    if (saved.message.uri !== message.uri) {
      return res.status(400).json({ error: "URI mismatch" });
    }

    if (new Date(message.expirationTime).getTime() < Date.now()) {
      return res.status(400).json({ error: "Message expired" });
    }

    const recovered = ethers.verifyTypedData(
      domain,
      LOGIN_TYPES,
      message,
      signature
    );

    if (ethers.getAddress(recovered) !== ethers.getAddress(address)) {
      return res.status(401).json({ error: "Signature verification failed" });
    }

    saved.used = true;

    const token = jwt.sign(
      {
        sub: ethers.getAddress(address),
        type: "session"
      },
      JWT_SECRET,
      { expiresIn: "2h" }
    );

    return res.json({
      success: true,
      token,
      address: ethers.getAddress(address)
    });
  } catch (err) {
    return res.status(500).json({ error: err.message });
  }
});

const AUTH_TYPES = {
  ActionAuthorization: [
    { name: "wallet", type: "address" },
    { name: "action", type: "string" },
    { name: "resourceId", type: "string" },
    { name: "nonce", type: "string" },
    { name: "deadline", type: "uint256" }
  ]
};

app.post("/auth/authorize-action", async (req, res) => {
  try {
    const { address, signature, payload } = req.body;

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

    const domain = {
      ...DOMAIN,
      chainId: payload.chainId || 1
    };

    if (Number(payload.deadline) < Math.floor(Date.now() / 1000)) {
      return res.status(400).json({ error: "Authorization expired" });
    }

    const message = {
      wallet: payload.wallet,
      action: payload.action,
      resourceId: payload.resourceId,
      nonce: payload.nonce,
      deadline: payload.deadline
    };

    const recovered = ethers.verifyTypedData(
      domain,
      AUTH_TYPES,
      message,
      signature
    );

    if (ethers.getAddress(recovered) !== ethers.getAddress(address)) {
      return res.status(401).json({ error: "Invalid action authorization" });
    }

    return res.json({
      success: true,
      authorized: true,
      action: payload.action,
      resourceId: payload.resourceId
    });
  } catch (err) {
    return res.status(500).json({ error: err.message });
  }
});

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

启动:

node server.js

前端:请求挑战并调用钱包签名

下面给一个简单的 React 组件示例。
新建 WalletLogin.jsx

import React, { useState } from "react";
import { ethers } from "ethers";

export default function WalletLogin() {
  const [account, setAccount] = useState("");
  const [token, setToken] = useState("");
  const [status, setStatus] = useState("");

  async function connectWallet() {
    if (!window.ethereum) {
      alert("Please install MetaMask");
      return;
    }

    const provider = new ethers.BrowserProvider(window.ethereum);
    const accounts = await provider.send("eth_requestAccounts", []);
    setAccount(accounts[0]);
  }

  async function loginWithEIP712() {
    try {
      setStatus("请求 challenge 中...");
      if (!window.ethereum) throw new Error("No wallet found");
      if (!account) throw new Error("Please connect wallet first");

      const provider = new ethers.BrowserProvider(window.ethereum);
      const signer = await provider.getSigner();
      const network = await provider.getNetwork();
      const chainId = Number(network.chainId);

      const challengeResp = await fetch(
        `http://localhost:3001/auth/challenge?address=${account}&chainId=${chainId}&uri=${encodeURIComponent(window.location.origin)}`
      );
      const challenge = await challengeResp.json();

      if (!challenge.domain) {
        throw new Error(challenge.error || "Failed to get challenge");
      }

      setStatus("等待钱包签名...");

      const signature = await signer.signTypedData(
        challenge.domain,
        challenge.types,
        challenge.message
      );

      setStatus("提交验签中...");

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

      const verifyResult = await verifyResp.json();

      if (!verifyResult.success) {
        throw new Error(verifyResult.error || "Verify failed");
      }

      setToken(verifyResult.token);
      setStatus("登录成功");
    } catch (err) {
      setStatus(`失败:${err.message}`);
    }
  }

  async function authorizeAction() {
    try {
      if (!window.ethereum) throw new Error("No wallet found");
      if (!account) throw new Error("Please connect wallet first");

      const provider = new ethers.BrowserProvider(window.ethereum);
      const signer = await provider.getSigner();
      const network = await provider.getNetwork();
      const chainId = Number(network.chainId);

      const domain = {
        name: "MyDapp Auth",
        version: "1",
        chainId,
        verifyingContract: "0x0000000000000000000000000000000000000000"
      };

      const types = {
        ActionAuthorization: [
          { name: "wallet", type: "address" },
          { name: "action", type: "string" },
          { name: "resourceId", type: "string" },
          { name: "nonce", type: "string" },
          { name: "deadline", type: "uint256" }
        ]
      };

      const payload = {
        wallet: account,
        action: "POST_ARTICLE",
        resourceId: "article:10001",
        nonce: crypto.randomUUID(),
        deadline: Math.floor(Date.now() / 1000) + 300,
        chainId
      };

      const signature = await signer.signTypedData(domain, types, {
        wallet: payload.wallet,
        action: payload.action,
        resourceId: payload.resourceId,
        nonce: payload.nonce,
        deadline: payload.deadline
      });

      const resp = await fetch("http://localhost:3001/auth/authorize-action", {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          address: account,
          signature,
          payload
        })
      });

      const result = await resp.json();

      if (!result.success) {
        throw new Error(result.error || "Authorization failed");
      }

      alert(`已授权动作: ${result.action}, 资源: ${result.resourceId}`);
    } catch (err) {
      alert(err.message);
    }
  }

  return (
    <div style={{ padding: 24 }}>
      <h2>EIP-712 登录与授权演示</h2>
      <p>当前账户:{account || "未连接"}</p>
      <button onClick={connectWallet}>连接钱包</button>
      <button onClick={loginWithEIP712} style={{ marginLeft: 12 }}>
        EIP-712 登录
      </button>
      <button onClick={authorizeAction} style={{ marginLeft: 12 }}>
        授权业务动作
      </button>
      <p style={{ marginTop: 16 }}>状态:{status}</p>
      {token && (
        <>
          <p>JWT:</p>
          <textarea
            readOnly
            value={token}
            style={{ width: 600, height: 120 }}
          />
        </>
      )}
    </div>
  );
}

注意:crypto.randomUUID() 在现代浏览器可用。如果你项目环境较旧,改成后端下发 nonce 更稳妥。


授权流程为什么不能直接复用登录签名

很多人上线前会想偷懒:
“既然已经登录了,那用户后续敏感操作就不用再签了吧?”

这取决于业务风险。

如果只是普通页面浏览、个人设置读取,session 足够。
但如果是这些场景,我建议单独再做授权签名:

  • 发布重要内容
  • 发起订单撮合
  • 提交链上交易前确认摘要
  • 允许第三方服务代表用户执行动作
  • 对资产、额度、权限做高风险操作

原因很简单:
登录证明“你是谁”,不等于你同意“现在执行这个动作”。


状态图:登录与动作授权的边界

stateDiagram-v2
    [*] --> Unauthenticated
    Unauthenticated --> ChallengeIssued: 请求登录挑战
    ChallengeIssued --> SignedLogin: 钱包签名
    SignedLogin --> Authenticated: 服务端验签成功
    Authenticated --> ActionPending: 发起敏感动作
    ActionPending --> ActionAuthorized: 签署授权消息
    ActionAuthorized --> Authenticated: 动作执行完毕
    Authenticated --> [*]

逐步验证清单

如果你希望边做边验证,而不是写完一大堆再一起崩,我建议按这个顺序:

第 1 步:只验证钱包连接

确认前端能拿到地址:

const accounts = await provider.send("eth_requestAccounts", []);
console.log(accounts[0]);

第 2 步:只拿 challenge,不签名

确认后端返回了完整的:

  • domain
  • types
  • message

第 3 步:本地打印签名对象

签名前输出:

console.log(JSON.stringify(challenge, null, 2));

这一步经常能看出链 ID、uri、字段名拼写问题。

第 4 步:签名后后端单独 recover

服务端先只做:

const recovered = ethers.verifyTypedData(domain, types, message, signature);
console.log(recovered);

确认 recover 出来的地址是对的,再继续做 nonce 校验、过期校验。

第 5 步:最后再接 JWT/session

不要一开始就把 cookie、session、权限系统全堆上去,不然排错范围太大。


常见坑与排查

这一节很重要。我自己做这类功能时,踩坑最多的不是“不会写”,而是前后端明明看起来一样,验签就是失败


坑 1:前后端的 types 不完全一致

比如前端是:

{ name: "deadline", type: "uint256" }

后端误写成:

{ name: "deadline", type: "string" }

这种情况下,签名一定验不过。

排查建议:

  • 前后端共用同一份 schema 定义
  • domain/types/message 完整打印出来逐项比对
  • 不要手工复制时改字段名

坑 2:chainId 不一致

前端钱包在 Polygon,后端却写死 chainId: 1
验签时会直接失败。

排查建议:

前端拿钱包实际网络:

const network = await provider.getNetwork();
const chainId = Number(network.chainId);

后端 challenge 也必须基于这个 chainId 生成。


坑 3:地址大小写与校验和问题

用户地址可能是小写,也可能是 checksum 格式。
直接字符串比较,很容易误判。

建议:

统一用:

ethers.getAddress(address)

做标准化后再比较。


坑 4:nonce 没有“一次性消费”

如果签名验证通过后,nonce 还可以重复使用,那你其实把登录签名变成了“可回放通行证”。

建议:

  • nonce 验签成功后立刻标记已使用
  • nonce 要有 TTL
  • nonce 与 address 绑定
  • 最好支持多终端时的 challenge 隔离

坑 5:前端把 challenge 改了

有的同学在前端签名前,会“顺手改一下 message”,比如重写了 uri 或时间格式。
只要改过一个字符,签名对象就不是后端发的那个对象了。

建议:

challenge 返回后,前端应视为只读数据。
如果要修改,必须重新向后端申请 challenge。


坑 6:钱包支持差异

大多数主流钱包支持 EIP-712,但不同注入式钱包、移动端钱包、WalletConnect 中转场景,细节表现可能不同。

建议:

  • 优先使用标准方法:signer.signTypedData(...)
  • 针对移动端和 WalletConnect 单独做兼容测试
  • 不要默认所有钱包对复杂嵌套 struct 都表现一致

坑 7:把登录签名长期复用

有些系统会把一次登录签名缓存下来,后续长期复用。
这会显著放大签名泄漏后的风险。

建议:

  • 登录 challenge 只短时有效,如 5 分钟
  • 登录 session 自己单独设过期
  • 高风险动作必须重新授权签名

安全/性能最佳实践

这一节我尽量讲“能直接执行”的建议。


1. 登录签名必须包含 nonce 和过期时间

最低配置建议:

  • nonce:128 bit 随机值以上
  • issuedAt:ISO 时间
  • expirationTime:5 分钟内

如果没有过期时间,旧 challenge 很可能被误用。


2. 做域隔离,别让不同环境串签

domain 至少要稳定包含:

  • name
  • version
  • chainId

业务层最好再在 message 里加入:

  • uri
  • audience
  • origin

这样测试环境签名就不容易拿到生产环境复用。


3. 登录与授权分层

推荐分三层:

  1. 钱包地址认证层:EIP-712 登录
  2. 会话层:JWT / session cookie
  3. 动作授权层:对高风险操作单独签名

这是一个很实用的架构边界。
不要每次请求都强制钱包签名,否则用户体验会非常差;
也不要所有动作都只靠 session,否则高风险操作缺乏显式确认。


4. nonce 存 Redis,不要只放进程内存

本文演示用了 Map,但生产不要这么做。原因有三个:

  • 服务重启后 challenge 丢失
  • 多实例部署时 challenge 不共享
  • 不利于统一 TTL 管理

生产建议:

  • Redis key:auth:challenge:${address}:${nonce}
  • TTL:300 秒
  • 验签成功后立即删除

5. 只信后端生成的 challenge

前端不要自己拼登录消息再提交给后端验签。
否则你很难保证字段语义、时效、资源边界都正确。

正确做法是:

  • 后端生成 challenge
  • 前端只负责请求、签名、回传
  • 后端严格按自己签发过的 challenge 验证

6. 对授权签名增加资源粒度

授权消息不要只写:

action = transfer

而应该更具体:

  • action = TRANSFER
  • resourceId = vault:123
  • amount = 1000000
  • token = 0x...
  • deadline = ...

签名越精确,滥用空间越小。


7. 注意时钟偏差

如果前端和后端、或服务节点之间时间差太大,过期判断可能误伤正常请求。

建议:

  • 以后端时间为准生成 challenge
  • 可以容忍几十秒的偏差窗口
  • 排查时先打印服务器时间和 message 时间

如果你把登录后的 token 放在 cookie 里,而不是前端内存或 Authorization header,那么还要同时考虑 CSRF。

常见方案:

  • SameSite=Lax/Strict
  • CSRF token
  • 敏感接口二次签名

别因为“都用钱包了”就忽略传统 Web 安全问题。


9. 为审计留证据,但不要记录敏感冗余

推荐记录:

  • address
  • nonce
  • issuedAt
  • expirationTime
  • 签名 hash 或原始 signature
  • recover 地址
  • IP / User-Agent(按合规需要)

但不要把不必要的用户敏感上下文无限堆日志里。


一个更稳的生产化改造方向

如果你准备把这套方案真正用于线上,我建议至少做这些升级:

后端升级项

  • challenge 存 Redis
  • JWT secret 放环境变量
  • 按地址限流挑战请求
  • 支持 nonce 单次消费与黑名单
  • 审计日志入库
  • 对授权动作增加幂等键

前端升级项

  • 登录前先校验链是否正确
  • 对签名失败、拒签、钱包断连做明确提示
  • 用统一 hooks 封装 challenge / sign / verify
  • 对移动端钱包做兼容测试

协议升级项

  • 明确定义 domain 规范
  • 明确定义 message schema 版本
  • 协议升级时保留 version
  • 必要时参考 SIWE(Sign-In with Ethereum)的字段设计思路

personal_sign 的取舍对比

不是说 personal_sign 完全不能用,而是它更适合:

  • 临时调试
  • 简单钱包所有权证明
  • 不需要结构化展示的轻场景

而 EIP-712 更适合:

  • 登录系统
  • 权限授权
  • 风险操作确认
  • 需要可读、可审计、可扩展的签名协议

简单对比:

维度personal_signEIP-712
用户可读性一般更好
结构化
钱包展示不稳定更清晰
字段扩展
登录授权场景勉强可用更推荐

如果你的项目是认真做产品,而不是 Demo,我更建议从 EIP-712 起步。


排查思路:验签失败时先看哪三样

如果你线上遇到“签名验不过”,我建议第一时间只盯住这三件事:

  1. domain 是否完全一致
  2. types 是否逐字段一致
  3. message 是否一字不差

很多时候不是密码学问题,就是对象不一致问题。
我自己排过最离谱的一次,是前端把 chainId 当字符串传了,后端按数字处理,最后看起来都像 1,但就是验不过。这个坑非常隐蔽。


总结

如果把这篇文章压缩成一句话,那就是:

用 EIP-712 做 Web3 登录,不只是“换一种签名方式”,而是在建立一套可读、可验证、可扩展的身份与授权协议。

你可以把落地方案记成这几个关键点:

  • 登录签名和业务授权签名分开
  • challenge 必须由后端生成
  • 每次登录都要有 nonce 和过期时间
  • 验签成功后 nonce 必须立即作废
  • domain/types/message 前后端必须完全一致
  • 高风险操作单独做授权签名,不要只靠 session
  • 生产环境把 challenge 放 Redis,并做好限流、审计、版本化

如果你现在的系统还停留在“签一段字符串就登录”,那下一步最值得做的升级,不是继续堆业务逻辑,而是先把签名协议结构化。
因为这个基础打牢了,后面你无论接 SIWE、接多钱包、接动作授权,都会顺很多。

如果要我给一个明确边界建议:

  • 轻量 Demopersonal_sign 可以先用
  • 正式登录系统:直接上 EIP-712
  • 涉及敏感业务动作:EIP-712 登录 + EIP-712 动作授权双层设计

这套方式不算最短路径,但它通常是更稳、更容易长期维护的路径。


分享到:

上一篇
《安卓逆向实战:基于 Frida 与 JADX 的应用签名校验与反调试绕过分析》
下一篇
《Java开发踩坑实战:定位并修复线程池误用导致的接口超时与内存飙升问题》