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

《Web3 中级实战:从钱包登录到链上签名验证的完整接入方案》

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

Web3 中级实战:从钱包登录到链上签名验证的完整接入方案

很多团队第一次做 Web3 登录,都会觉得“让用户连个钱包、签个名,不就完了?”
但真正落地时,问题很快冒出来:

  • 前端连上钱包了,后端怎么确认这个地址真的是用户本人?
  • 只验 addresssignature 够不够?会不会被重放攻击?
  • 要不要上链?什么场景适合链下验签,什么场景适合链上验签
  • EOA 可以,合约钱包(如 Safe)怎么办?
  • 登录态怎么和传统 Web2 的 session / JWT 融合?

这篇文章我会带你从一个可运行的最小方案出发,搭出一套完整链路:

  1. 前端发起钱包登录
  2. 后端生成 nonce 挑战消息
  3. 用户钱包签名
  4. 后端验签并签发 JWT
  5. 进阶:合约/业务场景下做链上签名验证

文章偏实战,我会尽量用“边做边解释”的方式讲,不只告诉你 API 怎么调,也告诉你为什么这样设计。


背景与问题

在 Web2 中,身份认证通常依赖:

  • 用户名 + 密码
  • 手机验证码
  • OAuth 第三方授权

在 Web3 中,最基础的身份载体变成了钱包地址
用户并不想再多记一个密码,而是希望用钱包完成登录。

但钱包登录和“连接钱包”不是一回事:

  • 连接钱包:只表示前端拿到了当前钱包地址
  • 钱包登录:必须证明“这个地址的私钥控制权属于当前用户”

这个“证明”通常通过签名完成。

一个常见但不完整的错误实现是:

  1. 前端拿到地址
  2. 让用户签一个固定字符串,比如 Login to MyApp
  3. 后端用签名恢复地址
  4. 一致就登录成功

问题在于:
固定消息可被重放。
如果签名被截获,攻击者可能反复拿它冒充用户登录。

所以一个靠谱的方案,至少要包括:

  • 一次性 nonce
  • 过期时间
  • 域名 / URI 绑定
  • Chain ID
  • 用户地址
  • 服务端会话管理

如果你接触过 SIWE(Sign-In with Ethereum, EIP-4361),会发现它本质上就是把这些字段规范化了。


前置知识与环境准备

为了让示例可运行,我下面使用这套技术栈:

  • 前端:React + wagmi + viem
  • 后端:Node.js + Express + ethers
  • 链上合约:Solidity + OpenZeppelin

你至少需要具备这些基础:

  • 知道 EOA 和合约钱包的区别
  • 理解 ECDSA 签名和地址恢复的大致概念
  • 会跑一个 Node 服务
  • 会使用 MetaMask 或其他 EVM 钱包

安装依赖

前端

npm install react wagmi viem @tanstack/react-query

后端

npm install express cors jsonwebtoken ethers uuid

合约开发(可选)

npm install @openzeppelin/contracts

先看整体链路

我们先把完整交互流程建立起来,后面每个步骤再拆开讲。

sequenceDiagram
    participant U as 用户
    participant FE as 前端应用
    participant W as 钱包
    participant BE as 后端服务
    participant CH as 链上合约

    U->>FE: 点击“钱包登录”
    FE->>W: 请求连接钱包
    W-->>FE: 返回 address
    FE->>BE: 请求 nonce
    BE-->>FE: 返回带 nonce 的登录消息
    FE->>W: personal_sign / signMessage
    W-->>FE: 返回 signature
    FE->>BE: 提交 address + message + signature
    BE->>BE: 验签、校验 nonce/过期时间/域名
    BE-->>FE: 签发 JWT / session
    FE->>CH: 提交业务请求(可选)
    CH->>CH: 链上验签(可选)

这个流程里有两个关键点:

  1. 登录认证通常优先在后端链下完成
  2. 链上验签通常用于合约内授权型业务,而不是普通网站登录

这是很多人一开始容易混的地方。


核心原理

1. 钱包登录的本质:证明私钥控制权

用户拥有一个地址,例如:

0x1234...abcd

当用户对一段消息签名时,后端可以从签名中恢复出签名者地址。
如果恢复出的地址和用户声称的地址一致,就说明这个用户确实控制该私钥。

这就是最基础的身份认证能力。


2. 为什么必须加 nonce

如果签名消息是固定的:

Login to MyApp

那么这个签名一旦泄露,就可以被无限次重放。

正确做法是让服务端每次生成一次性挑战消息,例如:

Welcome to MyApp

Address: 0xabc...
Nonce: 8e5b4b0e-xxxx
Chain ID: 1
Issued At: 2024-02-06T10:11:59Z
Expiration Time: 2024-02-06T10:16:59Z

服务端在验证签名时,同时验证:

  • nonce 是否存在且未使用
  • nonce 是否属于这个地址
  • 是否已过期
  • 域名是否匹配
  • chainId 是否符合预期

这样即使签名被截获,也很难再次复用。


3. 链下验签 vs 链上验签

这两个概念经常被混用,但目的并不一样。

链下验签

特点:

  • 不花 gas
  • 适合登录、接口鉴权、后台授权

典型场景:

  • 用户登录网站
  • 用签名确认一次站内操作
  • API 请求验签

链上验签

特点:

  • 在智能合约里完成验证
  • 可信、可组合
  • 花 gas
  • 适合链上授权逻辑

典型场景:

  • 白名单 mint 授权
  • 订单签名上链执行
  • Meta transaction
  • Permit / 委托执行

我自己的经验是:
“登录”大多数时候不要硬做链上验签,没必要。
链上验签应该服务于“合约必须自己判断签名是否有效”的业务。


4. EOA 与合约钱包的差异

普通外部账户(EOA)可以通过 ecrecover 恢复地址。
但合约钱包没有私钥,不能按 EOA 的方式验签。

这时要用 EIP-1271

  • EOA:ECDSA.recover
  • 合约钱包:调用合约的 isValidSignature

所以如果你的产品面向高级用户,只支持 EOA 验签是不够的


架构设计:推荐的登录方案

下面这个架构,是比较适合中级项目落地的:

flowchart TD
    A[前端连接钱包] --> B[后端签发 nonce challenge]
    B --> C[用户钱包签名]
    C --> D[后端链下验签]
    D --> E[签发 JWT / Session]
    E --> F[受保护 API]
    F --> G{是否需要链上授权?}
    G -- 否 --> H[普通业务处理]
    G -- 是 --> I[生成链上可验证签名]
    I --> J[合约中 ECDSA / EIP-1271 验签]

建议把“认证”和“链上业务授权”拆开:

  • 认证层:钱包登录 -> 后端 JWT/session
  • 业务层:需要上链时,再生成专用业务签名

这样做的好处是:

  • 登录流程简单
  • 后端权限模型清晰
  • 上链逻辑不会污染基础认证
  • 方便和 Web2 用户体系融合

实战代码(可运行)

下面我们做一个最小可跑通版本:

  • 前端:连接钱包 + 请求 challenge + 签名 + 登录
  • 后端:发 challenge + 验签 + 返回 JWT
  • 合约:演示如何做链上验签

一、后端实现:生成 challenge 与验签登录

1. 项目结构

server/
  index.js

2. 完整后端代码

const express = require("express");
const cors = require("cors");
const jwt = require("jsonwebtoken");
const { ethers } = require("ethers");
const { v4: uuidv4 } = require("uuid");

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

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

// demo 内存存储;生产环境请改成 Redis / DB
const nonceStore = new Map();

/**
 * 生成登录消息
 */
function buildMessage({ domain, address, uri, version, chainId, nonce, issuedAt, expirationTime }) {
  return `${domain} wants you to sign in with your Ethereum account:
${address}

Sign in to the app.

URI: ${uri}
Version: ${version}
Chain ID: ${chainId}
Nonce: ${nonce}
Issued At: ${issuedAt}
Expiration Time: ${expirationTime}`;
}

/**
 * 请求 challenge
 */
app.post("/auth/challenge", (req, res) => {
  const { address, chainId } = req.body;

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

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

  const message = buildMessage({
    domain: "localhost:5173",
    address,
    uri: "http://localhost:5173",
    version: "1",
    chainId: Number(chainId || 1),
    nonce,
    issuedAt,
    expirationTime,
  });

  nonceStore.set(nonce, {
    address: address.toLowerCase(),
    issuedAt,
    expirationTime,
    used: false,
  });

  res.json({
    message,
    nonce,
    issuedAt,
    expirationTime,
  });
});

/**
 * 验签并登录
 */
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 fields" });
    }

    const nonceMatch = message.match(/Nonce: (.+)/);
    const expirationMatch = message.match(/Expiration Time: (.+)/);
    const uriMatch = message.match(/URI: (.+)/);

    if (!nonceMatch || !expirationMatch || !uriMatch) {
      return res.status(400).json({ error: "Malformed message" });
    }

    const nonce = nonceMatch[1].trim();
    const expirationTime = expirationMatch[1].trim();
    const uri = uriMatch[1].trim();

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

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

    if (record.address !== address.toLowerCase()) {
      return res.status(400).json({ error: "Address mismatch with nonce" });
    }

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

    if (uri !== "http://localhost:5173") {
      return res.status(400).json({ error: "Invalid URI" });
    }

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

    if (recoveredAddress.toLowerCase() !== address.toLowerCase()) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    record.used = true;
    nonceStore.set(nonce, record);

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

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

/**
 * 受保护接口
 */
app.get("/me", (req, res) => {
  const auth = req.headers.authorization || "";
  const token = auth.replace("Bearer ", "");

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

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

3. 这段后端代码做了什么

核心有三件事:

  1. /auth/challenge
    服务端生成一次性消息

  2. /auth/verify
    ethers.verifyMessage 恢复签名地址,并核对 nonce、过期时间、URI

  3. 登录成功后签发 JWT
    后续请求走传统的 Bearer Token

这个模型非常适合把 Web3 登录接进已有后端系统。
你完全可以把 wallet address 当成一个特殊身份主键,然后继续接 RBAC、订单系统、用户画像等。


二、前端实现:连接钱包并发起登录

1. Wagmi 基础配置

import React from "react";
import ReactDOM from "react-dom/client";
import { http, createConfig, WagmiProvider } from "wagmi";
import { mainnet, sepolia } from "wagmi/chains";
import { injected } from "wagmi/connectors";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";

const config = createConfig({
  chains: [mainnet, sepolia],
  connectors: [injected()],
  transports: {
    [mainnet.id]: http(),
    [sepolia.id]: http(),
  },
});

const queryClient = new QueryClient();

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <App />
      </QueryClientProvider>
    </WagmiProvider>
  </React.StrictMode>
);

2. 登录页面组件

import { useState } from "react";
import {
  useAccount,
  useConnect,
  useDisconnect,
  useSignMessage,
  useChainId,
} from "wagmi";

export default function App() {
  const { address, isConnected } = useAccount();
  const { connect, connectors } = useConnect();
  const { disconnect } = useDisconnect();
  const { signMessageAsync } = useSignMessage();
  const chainId = useChainId();

  const [token, setToken] = useState("");
  const [me, setMe] = useState(null);
  const [loading, setLoading] = useState(false);

  const login = async () => {
    if (!address) return;
    setLoading(true);

    try {
      // 1. 请求 challenge
      const challengeResp = await fetch("http://localhost:3001/auth/challenge", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          address,
          chainId,
        }),
      });

      const challengeData = await challengeResp.json();
      if (!challengeResp.ok) {
        throw new Error(challengeData.error || "Challenge failed");
      }

      // 2. 钱包签名
      const signature = await signMessageAsync({
        message: challengeData.message,
      });

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

      const verifyData = await verifyResp.json();
      if (!verifyResp.ok) {
        throw new Error(verifyData.error || "Verify failed");
      }

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

  const fetchMe = async () => {
    if (!token) return;

    const resp = await fetch("http://localhost:3001/me", {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });

    const data = await resp.json();
    if (resp.ok) {
      setMe(data);
    } else {
      alert(data.error || "获取用户信息失败");
    }
  };

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

      {!isConnected ? (
        <button onClick={() => connect({ connector: connectors[0] })}>
          连接钱包
        </button>
      ) : (
        <>
          <p>当前地址:{address}</p>
          <p>当前链 ID:{chainId}</p>

          <button onClick={login} disabled={loading}>
            {loading ? "登录中..." : "签名登录"}
          </button>

          <button onClick={() => disconnect()} style={{ marginLeft: 12 }}>
            断开钱包
          </button>
        </>
      )}

      {token && (
        <div style={{ marginTop: 24 }}>
          <p>JWT:</p>
          <textarea value={token} readOnly rows={6} cols={80} />
          <div>
            <button onClick={fetchMe}>获取当前用户信息</button>
          </div>
        </div>
      )}

      {me && (
        <pre style={{ marginTop: 16 }}>
          {JSON.stringify(me, null, 2)}
        </pre>
      )}
    </div>
  );
}

三、逐步验证清单

如果你想确认整条链路真跑通了,可以按这个顺序检查:

  1. 启动后端:node index.js
  2. 启动前端开发服务器
  3. 打开页面,连接 MetaMask
  4. 点击“签名登录”
  5. 钱包弹窗显示登录消息,确认签名
  6. 页面拿到 JWT
  7. 点击“获取当前用户信息”
  8. 成功返回钱包地址

如果第 4 步到第 6 步失败,先别慌,后面“常见坑与排查”会逐项讲。


四、链上签名验证:合约内如何验签

前面的登录其实已经够用了。
但如果你的业务要求“合约自己验证签名”,就需要链上验签。

下面先演示 EOA 场景。

1. Solidity 合约示例

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract SignatureVerifier {
    using ECDSA for bytes32;

    function getMessageHash(
        address user,
        uint256 amount,
        uint256 nonce
    ) public pure returns (bytes32) {
        return keccak256(abi.encodePacked(user, amount, nonce));
    }

    function getEthSignedMessageHash(bytes32 messageHash) public pure returns (bytes32) {
        return messageHash.toEthSignedMessageHash();
    }

    function recoverSigner(
        bytes32 ethSignedMessageHash,
        bytes memory signature
    ) public pure returns (address) {
        return ECDSA.recover(ethSignedMessageHash, signature);
    }

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

2. 对应前端或脚本签名

const { ethers } = require("ethers");

async function main() {
  const wallet = new ethers.Wallet("YOUR_PRIVATE_KEY");

  const user = "0x1111111111111111111111111111111111111111";
  const amount = 100;
  const nonce = 1;

  const messageHash = ethers.solidityPackedKeccak256(
    ["address", "uint256", "uint256"],
    [user, amount, nonce]
  );

  const signature = await wallet.signMessage(ethers.getBytes(messageHash));

  console.log("messageHash:", messageHash);
  console.log("signature:", signature);
}

main();

这类签名一般不用于网页登录,而用于:

  • 后端签发 mint 授权
  • 签发优惠额度
  • 签发可上链执行的订单许可

五、支持合约钱包:EIP-1271 思路

如果你要兼容 Safe 等合约钱包,就不能只靠 ECDSA.recover

标准接口是:

function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4);

返回值应为:

0x1626ba7e

你可以在合约中这么处理:

flowchart LR
    A[收到 signer 地址] --> B{signer 是合约地址?}
    B -- 否 --> C[ECDSA.recover]
    B -- 是 --> D[调用 EIP-1271 isValidSignature]
    C --> E[返回验证结果]
    D --> E

EIP-1271 验签辅助示例

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

interface IERC1271 {
    function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4);
}

contract UniversalSignatureVerifier {
    using ECDSA for bytes32;

    bytes4 internal constant MAGICVALUE = 0x1626ba7e;

    function isValidSignatureNow(
        address signer,
        bytes32 messageHash,
        bytes memory signature
    ) public view returns (bool) {
        if (signer.code.length == 0) {
            address recovered = ECDSA.recover(messageHash, signature);
            return recovered == signer;
        } else {
            try IERC1271(signer).isValidSignature(messageHash, signature) returns (bytes4 magicValue) {
                return magicValue == MAGICVALUE;
            } catch {
                return false;
            }
        }
    }
}

在真实项目里,建议直接参考 OpenZeppelin 的 SignatureChecker,别自己重复造轮子。


常见坑与排查

这一段非常重要。我把我自己和团队里最常遇到的坑放在这里。

1. verifyMessage 通过不了

现象

后端恢复出来的地址不等于前端地址。

常见原因

  • 前端签名的是 message A,后端验证的是 message B
  • 文本换行符不一致
  • 前端用了 signTypedData,后端却用 verifyMessage
  • 前端对哈希签名,后端对原文验签

排查建议

先把这三项打印出来:

console.log({ address, message, signature });
console.log("recovered:", ethers.verifyMessage(message, signature));

如果你签的是普通字符串消息,就用:

ethers.verifyMessage(message, signature)

如果你签的是 EIP-712 typed data,就必须使用对应的 typed data 验签方法,不能混用。


2. 用户切链后登录失效

现象

用户在 A 链签名成功,切到 B 链后操作异常。

原因

你把 chainId 写进 challenge 里了,但后续业务没有校验当前链环境。

建议

  • 登录态和业务链上下文分开管理
  • 登录只证明地址控制权
  • 涉及链上业务时,再单独校验 chainId

如果你的产品是强链绑定型应用,比如只支持 Base 或 Arbitrum,那就直接在 challenge 和前端钱包层都限制好。


3. nonce 已使用,但用户说“我只点了一次”

原因

常见于前端重复提交:

  • React 严格模式下 effect 重跑
  • 用户双击按钮
  • 网络重试导致二次请求

建议

  • 登录按钮加 loading 禁用
  • challenge 与 verify 设计短时效
  • nonce 使用后立即作废
  • 后端做好幂等性控制

这个坑我真的踩过,尤其在开发环境里非常容易误判成“钱包有问题”。


4. 钱包弹窗签名内容太难懂,用户不敢签

原因

消息格式过于原始或像乱码。

建议

尽量提供可读性强的消息,比如:

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

Sign in to Example.

URI: https://example.com
Version: 1
Chain ID: 1
Nonce: xxxx
Issued At: xxxx
Expiration Time: xxxx

这也是 SIWE 的价值之一:
让签名消息“长得像登录消息”,而不是一坨技术文本。


5. 合约钱包用户无法登录

原因

你的后端只支持 EOA 的地址恢复逻辑。

建议

如果产品面向高阶用户或机构钱包,提前考虑:

  • 是否支持 EIP-1271
  • 是否直接集成成熟 SIWE 库
  • 是否允许多种钱包类型共存

安全/性能最佳实践

这一部分我建议你在正式上线前逐条对照。

安全最佳实践

1. Challenge 必须一次性、短时有效

建议:

  • nonce 随机且不可预测
  • 有效期 3~10 分钟
  • 验证成功立即作废

不要让同一个 challenge 长期可用。


2. 永远不要用固定消息做登录签名

错误示例:

Login to DApp

正确做法:

  • 带 nonce
  • 带 issuedAt / expirationTime
  • 带 domain / uri
  • 带 address / chainId

3. 绑定域名与来源

后端应校验:

  • domain
  • uri
  • 请求来源域名
  • CORS 配置

这样可以降低跨站伪造签名场景的风险。


4. 区分登录签名与业务签名

不要把“登录签名”直接拿去做“转账授权”或“mint 授权”。

建议分开:

  • 登录签名:证明身份
  • 业务签名:证明某次链上操作授权

两者消息结构、用途、过期策略都应该不同。


5. 合约内验签优先使用成熟库

例如:

  • OpenZeppelin ECDSA
  • OpenZeppelin SignatureChecker

不要手写底层椭圆曲线逻辑。
一旦实现有偏差,后果一般不是“偶发 bug”,而是“授权绕过”。


性能最佳实践

1. nonce 存 Redis,不要只放内存

示例里用 Map 是为了演示简单。
生产环境建议:

  • Redis 存 nonce
  • 设置 TTL
  • 支持多实例部署
  • 避免服务重启丢状态

2. JWT 只存必要身份信息

不要把过多业务字段塞进 token。
推荐最少包含:

{
  "sub": "0x...",
  "wallet": "0x..."
}

其他信息按需查库。


3. 把签名验证放在认证边界层

例如:

  • API Gateway
  • Auth Service
  • BFF 层

不要在每个业务服务里都重复实现一遍验签逻辑。
这样更方便统一升级和审计。


4. 上链验签只用于必须上链的业务

链上验签要花 gas。
如果只是网站登录,不要“为了去中心化而去中心化”。

我的经验是:
把链上能力用在不可替代的地方,而不是所有地方。


方案边界与取舍

到这里,你大概会问:那我到底该怎么选?

适合只做链下验签的场景

  • DApp 官网登录
  • 社区站点
  • 任务平台
  • 后台管理系统
  • 用户身份绑定

必须考虑链上验签的场景

  • 合约内白名单授权
  • 签名订单撮合
  • Meta Transaction
  • Permit / Delegation
  • 钱包外生成授权,链上执行结算

必须考虑 EIP-1271 的场景

  • 面向机构用户
  • 支持 Safe / 合约钱包
  • 高价值操作授权
  • 要求钱包类型兼容性

一个更稳妥的生产落地建议

如果你要的是“能上线、能维护、能扩展”的方案,我建议按这个层次来做:

  1. 第一阶段

    • 前端连接钱包
    • 后端 challenge + 验签
    • 签发 JWT
    • Redis 管理 nonce
  2. 第二阶段

    • 升级为 SIWE 标准消息
    • 增加 domain / uri / chainId 严格校验
    • 审计日志记录签名事件
  3. 第三阶段

    • 支持 EIP-1271
    • 登录与链上业务授权分离
    • 针对高风险操作使用 typed data 签名

这是一个比较现实的演进路径,不会一开始就把系统搞得过重。


总结

从工程角度看,Web3 钱包登录并不神秘,它本质上是:

  • 服务端发起挑战
  • 用户用钱包签名
  • 后端验证签名与上下文
  • 再回到熟悉的 JWT / session 体系

你真正要守住的,不是“能不能签上”,而是这几个点:

  • 签名消息必须一次性、可过期、可追踪
  • 登录认证优先链下完成
  • 链上验签只用于必须由合约判断的授权逻辑
  • 如果要支持合约钱包,必须考虑 EIP-1271
  • 不要混用不同签名类型的验证方法

如果你现在正在接一个中级 Web3 项目,我建议你直接从这套最小方案开始:

  1. 先把 challenge-login 跑通
  2. 把 nonce 放进 Redis
  3. 引入 SIWE 规范消息
  4. 再根据业务决定是否补链上验签和 EIP-1271

这样既不会过度设计,也能把安全底线守住。
很多项目不是死在“不会做”,而是死在“以为简单,所以少做了那几个关键校验”。这部分,真的值得认真一点。


分享到:

上一篇
《Java 开发踩坑实战:排查并修复线程池误用导致的接口响应变慢与内存飙升》
下一篇
《Docker 多阶段构建与镜像瘦身实战:从构建优化到安全交付》