Web3 中级实战:从钱包登录到链上签名验证的完整接入方案
很多团队第一次做 Web3 登录,都会觉得“让用户连个钱包、签个名,不就完了?”
但真正落地时,问题很快冒出来:
- 前端连上钱包了,后端怎么确认这个地址真的是用户本人?
- 只验
address和signature够不够?会不会被重放攻击? - 要不要上链?什么场景适合链下验签,什么场景适合链上验签?
- EOA 可以,合约钱包(如 Safe)怎么办?
- 登录态怎么和传统 Web2 的 session / JWT 融合?
这篇文章我会带你从一个可运行的最小方案出发,搭出一套完整链路:
- 前端发起钱包登录
- 后端生成 nonce 挑战消息
- 用户钱包签名
- 后端验签并签发 JWT
- 进阶:合约/业务场景下做链上签名验证
文章偏实战,我会尽量用“边做边解释”的方式讲,不只告诉你 API 怎么调,也告诉你为什么这样设计。
背景与问题
在 Web2 中,身份认证通常依赖:
- 用户名 + 密码
- 手机验证码
- OAuth 第三方授权
在 Web3 中,最基础的身份载体变成了钱包地址。
用户并不想再多记一个密码,而是希望用钱包完成登录。
但钱包登录和“连接钱包”不是一回事:
- 连接钱包:只表示前端拿到了当前钱包地址
- 钱包登录:必须证明“这个地址的私钥控制权属于当前用户”
这个“证明”通常通过签名完成。
一个常见但不完整的错误实现是:
- 前端拿到地址
- 让用户签一个固定字符串,比如
Login to MyApp - 后端用签名恢复地址
- 一致就登录成功
问题在于:
固定消息可被重放。
如果签名被截获,攻击者可能反复拿它冒充用户登录。
所以一个靠谱的方案,至少要包括:
- 一次性 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. 钱包登录的本质:证明私钥控制权
用户拥有一个地址,例如:
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. 这段后端代码做了什么
核心有三件事:
-
/auth/challenge
服务端生成一次性消息 -
/auth/verify
用ethers.verifyMessage恢复签名地址,并核对 nonce、过期时间、URI -
登录成功后签发 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>
);
}
三、逐步验证清单
如果你想确认整条链路真跑通了,可以按这个顺序检查:
- 启动后端:
node index.js - 启动前端开发服务器
- 打开页面,连接 MetaMask
- 点击“签名登录”
- 钱包弹窗显示登录消息,确认签名
- 页面拿到 JWT
- 点击“获取当前用户信息”
- 成功返回钱包地址
如果第 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. 绑定域名与来源
后端应校验:
domainuri- 请求来源域名
- 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 / 合约钱包
- 高价值操作授权
- 要求钱包类型兼容性
一个更稳妥的生产落地建议
如果你要的是“能上线、能维护、能扩展”的方案,我建议按这个层次来做:
-
第一阶段
- 前端连接钱包
- 后端 challenge + 验签
- 签发 JWT
- Redis 管理 nonce
-
第二阶段
- 升级为 SIWE 标准消息
- 增加 domain / uri / chainId 严格校验
- 审计日志记录签名事件
-
第三阶段
- 支持 EIP-1271
- 登录与链上业务授权分离
- 针对高风险操作使用 typed data 签名
这是一个比较现实的演进路径,不会一开始就把系统搞得过重。
总结
从工程角度看,Web3 钱包登录并不神秘,它本质上是:
- 服务端发起挑战
- 用户用钱包签名
- 后端验证签名与上下文
- 再回到熟悉的 JWT / session 体系
你真正要守住的,不是“能不能签上”,而是这几个点:
- 签名消息必须一次性、可过期、可追踪
- 登录认证优先链下完成
- 链上验签只用于必须由合约判断的授权逻辑
- 如果要支持合约钱包,必须考虑 EIP-1271
- 不要混用不同签名类型的验证方法
如果你现在正在接一个中级 Web3 项目,我建议你直接从这套最小方案开始:
- 先把 challenge-login 跑通
- 把 nonce 放进 Redis
- 引入 SIWE 规范消息
- 再根据业务决定是否补链上验签和 EIP-1271
这样既不会过度设计,也能把安全底线守住。
很多项目不是死在“不会做”,而是死在“以为简单,所以少做了那几个关键校验”。这部分,真的值得认真一点。