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,会埋坑。
方案概览
我建议把系统拆成两层:
- 链下身份层:完成登录、会话管理、基础权限控制
- 链上验证层:在需要上链可信执行时,对签名做合约内验证
这样做的好处是:
- 大部分登录请求走后端,成本低、延迟小
- 只有关键动作才走链上,避免把所有事情都做成昂贵的 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:
- 用户提供钱包地址
- 服务端生成一次性
nonce - 前端把业务上下文和 nonce 组装成消息
- 钱包签名
- 服务端恢复签名者地址并比对
- 验证通过后签发会话
关键点是:签名的不是密码,而是一段带上下文的声明文本。
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_signeth_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。
这时后端要做的是:
- 先判断地址是否是合约地址
- 如果是 EOA,用
verifyMessage - 如果是合约地址,调用目标合约的
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 项目,我给你的可执行建议是:
- 先用
personal_sign + nonce + 后端 verifyMessage + JWT跑通主流程 - nonce 放 Redis,5 分钟过期,成功即销毁
- 登录消息带上 domain、chainId、issuedAt、expirationTime、purpose
- 关键合约操作单独设计链上验签,不要复用“登录消息”
- 计划支持合约钱包时,补上 EIP-1271
- 当业务授权字段变复杂,升级到 EIP-712 / SIWE
最后给一个边界判断:
- 只是网站登录:链下验签足够
- 涉及资产或合约授权:必须设计链上验证
- 涉及多签/AA 钱包:不能只靠
ecrecover
把这几条边界想清楚,你的 Web3 登录系统就不会停留在 Demo,而是能真正服务生产环境。