Web3 中级实战:基于 EIP-712 与钱包签名实现去中心化登录(SIWE)完整方案
在 Web3 应用里,“登录”这件事和传统系统很不一样。
我们习惯了用户名 + 密码,或者手机号验证码,但到了链上世界,用户真正掌握身份的方式,其实是钱包私钥。这就引出一个很自然的问题:
能不能不再存密码,而是直接让用户用钱包签名完成登录?
答案当然是可以,而且这已经是很多 DApp 的标准姿势。本文我会带你从业务背景、EIP-712 原理、后端验签、会话建立、安全细节一路走完,做出一个可运行的 SIWE(Sign-In with Ethereum)登录方案。
这篇文章偏中级实战,我默认你已经对钱包、签名、公私钥这些概念不陌生,但如果你还没真正把“钱包签名登录”从前端到后端串起来,这篇会比较合适。
一、背景与问题
1.1 传统登录为什么不适合 Web3
在传统 Web 应用中,身份认证通常依赖:
- 用户名/密码
- 短信验证码
- OAuth 第三方登录
- 服务端保存账户体系
但在 Web3 里,很多用户天然已经有了自己的身份载体:钱包地址。
如果还要求他再注册一个账号、设置一遍密码,体验会非常割裂。
更关键的是:
- 密码体系增加了服务端泄漏风险
- Web3 用户更习惯“连接钱包即身份”
- 链上地址本身可作为统一身份标识
所以去中心化登录的核心诉求就是:
- 用户证明“我确实控制这个地址”
- 服务端验证签名合法
- 服务端发放会话或 Token
- 后续请求沿用这个会话
1.2 为什么不能直接“签一段字符串”就完了
很多早期教程会这么写:
Please sign this message to login: 123456
这看起来简单,但有几个问题:
- 消息结构不规范,前后端容易约定混乱
- 可读性差,用户在钱包里看不清签了什么
- 容易被重放攻击
- 域信息(domain、站点、chainId)不明确
- 后续扩展字段困难
因此,中级以上项目我更建议直接采用结构化签名,也就是 EIP-712。
而如果你希望贴近登录语义,则可以把 SIWE(EIP-4361) 的思路和 EIP-712 结合起来实现。
二、前置知识与环境准备
本文示例技术栈:
- 前端:React + Vite
- 钱包接入:MetaMask / 兼容 EIP-1193 的钱包
- 后端:Node.js + Express
- 签名库:ethers v6
- 会话:JWT(也可以换成 HttpOnly Cookie)
- 数据存储:内存示例,生产建议 Redis / DB
安装依赖如下。
前端
npm install ethers
后端
npm install express cors ethers jsonwebtoken
如果你喜欢 TypeScript,也可以自行补上类型定义。为了让代码更直观,本文用 JavaScript 展示。
三、核心原理
先把整条链路捋顺。去中心化登录本质上不是“链上交易”,而是离线签名 + 服务端验签。
3.1 整体流程
flowchart TD
A[用户连接钱包] --> B[前端请求服务端生成 nonce]
B --> C[服务端返回 nonce 与登录上下文]
C --> D[前端构造 EIP-712 Typed Data]
D --> E[钱包弹窗请求签名]
E --> F[前端提交 address + signature + message]
F --> G[服务端使用 EIP-712 验签]
G --> H{验签通过且 nonce 未使用?}
H -- 是 --> I[标记 nonce 已消费]
I --> J[签发 JWT / Session]
H -- 否 --> K[拒绝登录]
3.2 EIP-712 到底解决了什么
EIP-712 的核心价值是:让签名内容结构化、可读、可验证、可扩展。
和普通字符串签名相比,它包含三部分:
domain:签名域,限定应用上下文types:结构体定义message:待签名的数据内容
这样做的好处:
- 钱包能更友好地展示签名内容
- 服务端可以严格按结构验签
- 能天然纳入
chainId、domain、uri、nonce、issuedAt等字段 - 更适合登录、授权、Permit 等场景
3.3 SIWE 在这里扮演什么角色
SIWE(Sign-In with Ethereum)强调的是一种登录消息规范。
它告诉你,一个“以太坊登录消息”应该有哪些关键字段,例如:
- domain
- address
- statement
- uri
- version
- chainId
- nonce
- issuedAt
- expirationTime
严格来说,SIWE 传统上更常见的是基于可读文本格式的签名消息;而本文采用的做法是:
借鉴 SIWE 的字段语义,用 EIP-712 来承载结构化登录消息。
这在工程上非常实用:
既有 SIWE 的登录语义,又有 EIP-712 的结构化优势。
3.4 登录消息结构设计
这里我们定义一个 Login 类型:
const types = {
Login: [
{ name: 'domain', type: 'string' },
{ name: 'address', type: 'address' },
{ name: 'statement', type: 'string' },
{ name: 'uri', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'nonce', type: 'string' },
{ name: 'issuedAt', type: 'string' },
{ name: 'expirationTime', type: 'string' }
]
}
其中最关键的安全字段是:
nonce:防重放issuedAt:签发时间expirationTime:过期时间chainId:限制链环境domain/uri:限制业务上下文
3.5 验签原理
EIP-712 验签的本质是:
- 服务端拿到 typed data
- 对 typed data 做相同哈希
- 用签名恢复出签名者地址
- 比较恢复地址是否等于用户声称的地址
流程如下:
sequenceDiagram
participant U as 用户钱包
participant F as 前端
participant S as 服务端
F->>S: GET /auth/nonce?address=0x...
S-->>F: nonce + issuedAt + expirationTime
F->>U: signTypedData(domain, types, message)
U-->>F: signature
F->>S: POST /auth/verify {message, signature}
S->>S: verifyTypedData(...)
S->>S: 校验 nonce / 时间 / domain / chainId
S-->>F: JWT / Session
四、实战代码(可运行)
下面我们直接做一个最小可运行版本。
目录结构:
web3-siwe-demo/
├─ server.js
└─ frontend-example.js
这里前端代码我会写成一个独立示例函数,方便你直接迁移到 React/Vue 项目里。
4.1 后端:生成 nonce 与验签登录
server.js
const express = require('express')
const cors = require('cors')
const crypto = require('crypto')
const jwt = require('jsonwebtoken')
const { ethers } = require('ethers')
const app = express()
app.use(cors())
app.use(express.json())
const PORT = 3001
const JWT_SECRET = 'replace-this-in-production'
/**
* 简化演示:
* nonceStore 记录每个 address 当前可用 nonce
* usedNonceStore 记录已消费 nonce
*
* 生产环境建议:
* - Redis 存储
* - 设置 TTL
* - nonce 一次性消费
*/
const nonceStore = new Map()
const usedNonceStore = new Set()
function generateNonce() {
return crypto.randomBytes(16).toString('hex')
}
function nowISO() {
return new Date().toISOString()
}
function plusMinutesISO(minutes) {
return new Date(Date.now() + minutes * 60 * 1000).toISOString()
}
function getDomain(chainId) {
return {
name: 'Demo SIWE App',
version: '1',
chainId,
verifyingContract: '0x0000000000000000000000000000000000000000'
}
}
function getTypes() {
return {
Login: [
{ name: 'domain', type: 'string' },
{ name: 'address', type: 'address' },
{ name: 'statement', type: 'string' },
{ name: 'uri', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'nonce', type: 'string' },
{ name: 'issuedAt', type: 'string' },
{ name: 'expirationTime', type: 'string' }
]
}
}
app.get('/auth/nonce', (req, res) => {
const { address, chainId } = req.query
if (!address || !ethers.isAddress(address)) {
return res.status(400).json({ error: 'Invalid address' })
}
const parsedChainId = Number(chainId || 1)
if (!Number.isInteger(parsedChainId) || parsedChainId <= 0) {
return res.status(400).json({ error: 'Invalid chainId' })
}
const nonce = generateNonce()
const issuedAt = nowISO()
const expirationTime = plusMinutesISO(10)
nonceStore.set(address.toLowerCase(), {
nonce,
issuedAt,
expirationTime,
chainId: parsedChainId
})
return res.json({
nonce,
issuedAt,
expirationTime,
domainData: getDomain(parsedChainId),
types: getTypes()
})
})
app.post('/auth/verify', (req, res) => {
try {
const { message, signature } = req.body
if (!message || !signature) {
return res.status(400).json({ error: 'Missing message or signature' })
}
const {
domain,
address,
statement,
uri,
version,
chainId,
nonce,
issuedAt,
expirationTime
} = message
if (!ethers.isAddress(address)) {
return res.status(400).json({ error: 'Invalid address in message' })
}
const stored = nonceStore.get(address.toLowerCase())
if (!stored) {
return res.status(400).json({ error: 'Nonce not found, request nonce first' })
}
if (stored.nonce !== nonce) {
return res.status(400).json({ error: 'Nonce mismatch' })
}
if (stored.chainId !== Number(chainId)) {
return res.status(400).json({ error: 'ChainId mismatch' })
}
if (usedNonceStore.has(`${address.toLowerCase()}:${nonce}`)) {
return res.status(400).json({ error: 'Nonce already used' })
}
const now = Date.now()
if (new Date(expirationTime).getTime() < now) {
return res.status(400).json({ error: 'Message expired' })
}
if (new Date(issuedAt).getTime() - now > 5 * 60 * 1000) {
return res.status(400).json({ error: 'IssuedAt is too far in the future' })
}
// 可额外校验业务字段
if (domain !== 'localhost:5173') {
return res.status(400).json({ error: 'Invalid domain' })
}
if (uri !== 'http://localhost:5173') {
return res.status(400).json({ error: 'Invalid uri' })
}
if (version !== '1') {
return res.status(400).json({ error: 'Invalid version' })
}
if (statement !== 'Sign in to Demo SIWE App') {
return res.status(400).json({ error: 'Invalid statement' })
}
const recoveredAddress = ethers.verifyTypedData(
getDomain(Number(chainId)),
getTypes(),
message,
signature
)
if (recoveredAddress.toLowerCase() !== address.toLowerCase()) {
return res.status(401).json({ error: 'Signature verification failed' })
}
usedNonceStore.add(`${address.toLowerCase()}:${nonce}`)
nonceStore.delete(address.toLowerCase())
const token = jwt.sign(
{
sub: address.toLowerCase(),
wallet: address.toLowerCase(),
chainId: Number(chainId)
},
JWT_SECRET,
{ expiresIn: '2h' }
)
return res.json({
success: true,
token,
address: address.toLowerCase()
})
} catch (err) {
console.error(err)
return res.status(500).json({ error: 'Internal server error' })
}
})
app.get('/me', (req, res) => {
const auth = req.headers.authorization || ''
const token = auth.startsWith('Bearer ') ? auth.slice(7) : null
if (!token) {
return res.status(401).json({ error: 'Missing token' })
}
try {
const payload = jwt.verify(token, JWT_SECRET)
return res.json({ user: payload })
} catch (err) {
return res.status(401).json({ error: 'Invalid token' })
}
})
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`)
})
启动:
node server.js
4.2 前端:连接钱包并发起 EIP-712 登录
frontend-example.js
import { ethers } from 'ethers'
const API_BASE = 'http://localhost:3001'
export async function loginWithWallet() {
if (!window.ethereum) {
throw new Error('MetaMask not found')
}
const provider = new ethers.BrowserProvider(window.ethereum)
await provider.send('eth_requestAccounts', [])
const signer = await provider.getSigner()
const address = await signer.getAddress()
const network = await provider.getNetwork()
const chainId = Number(network.chainId)
// 1. 获取后端生成的 nonce
const nonceResp = await fetch(
`${API_BASE}/auth/nonce?address=${address}&chainId=${chainId}`
)
const nonceData = await nonceResp.json()
if (!nonceResp.ok) {
throw new Error(nonceData.error || 'Failed to get nonce')
}
const domain = nonceData.domainData
const types = nonceData.types
// 2. 构造结构化登录消息
const message = {
domain: window.location.host,
address,
statement: 'Sign in to Demo SIWE App',
uri: window.location.origin,
version: '1',
chainId,
nonce: nonceData.nonce,
issuedAt: nonceData.issuedAt,
expirationTime: nonceData.expirationTime
}
// 3. 发起 EIP-712 签名
const signature = await signer.signTypedData(domain, types, message)
// 4. 提交服务端验签
const verifyResp = await fetch(`${API_BASE}/auth/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
message,
signature
})
})
const verifyData = await verifyResp.json()
if (!verifyResp.ok) {
throw new Error(verifyData.error || 'Login failed')
}
localStorage.setItem('token', verifyData.token)
return verifyData
}
export async function getCurrentUser() {
const token = localStorage.getItem('token')
if (!token) throw new Error('Not logged in')
const resp = await fetch(`${API_BASE}/me`, {
headers: {
Authorization: `Bearer ${token}`
}
})
const data = await resp.json()
if (!resp.ok) {
throw new Error(data.error || 'Failed to get user')
}
return data
}
4.3 React 页面最小接入示例
如果你在 React 里测试,可以这样写:
import React, { useState } from 'react'
import { loginWithWallet, getCurrentUser } from './frontend-example'
export default function App() {
const [result, setResult] = useState(null)
const [error, setError] = useState('')
const handleLogin = async () => {
try {
setError('')
const data = await loginWithWallet()
setResult(data)
} catch (e) {
setError(e.message)
}
}
const handleMe = async () => {
try {
setError('')
const data = await getCurrentUser()
setResult(data)
} catch (e) {
setError(e.message)
}
}
return (
<div style={{ padding: 24 }}>
<h1>Demo SIWE Login</h1>
<button onClick={handleLogin}>钱包登录</button>
<button onClick={handleMe} style={{ marginLeft: 12 }}>
获取当前用户
</button>
{error && <pre style={{ color: 'red' }}>{error}</pre>}
{result && <pre>{JSON.stringify(result, null, 2)}</pre>}
</div>
)
}
4.4 逐步验证清单
建议你按下面顺序验证,而不是一把梭哈上线。我自己做这类功能时,通常都这么拆。
第一步:确认钱包连接成功
检查:
eth_requestAccounts是否弹窗- 是否拿到了正确地址
- 当前
chainId是否符合预期
第二步:确认 nonce 接口返回合理
检查服务端是否返回:
nonceissuedAtexpirationTimedomainDatatypes
第三步:确认钱包成功弹出签名框
检查钱包签名界面里是否能看到:
- 登录语句
- 地址
- 域名
- nonce
- 有效时间
第四步:确认服务端验签通过
关注服务端日志:
- 是否拿到
message和signature verifyTypedData是否恢复出正确地址- nonce 是否被成功消费
第五步:确认登录后会话可用
验证:
- JWT 是否成功保存
/me接口是否能返回用户身份- 过期后是否正确拒绝访问
五、常见坑与排查
这一段很重要。EIP-712 登录最烦人的地方不是“不会写”,而是“看起来都对,但就是验不过”。
5.1 domain 不一致导致验签失败
最常见的问题之一是:
- 前端签名时用的
domain - 后端验签时构造的
domain
这两者只要有一个字段不同,验签就会失败。
比如这些字段都必须严格一致:
nameversionchainIdverifyingContract
很多人会忽略 verifyingContract。
如果前端有,后端没有;或者前端是某个地址,后端是零地址,都会失败。
排查建议
把前端签名前的 domain/types/message 完整打印出来,同时在后端也打印一份,逐字段比对。
5.2 types 定义顺序或字段类型不一致
例如前端:
{ name: 'chainId', type: 'uint256' }
后端误写成:
{ name: 'chainId', type: 'string' }
这会直接导致恢复地址不一致。
还有一个容易忽略的点:字段顺序也必须一致。
不要觉得 JSON 是无序的,EIP-712 的结构定义顺序是有意义的。
5.3 地址大小写导致比较误判
恢复出来的地址可能是 checksum 格式,而你保存的是全小写。
错误写法:
if (recoveredAddress !== address) { ... }
正确写法:
if (recoveredAddress.toLowerCase() !== address.toLowerCase()) { ... }
当然,如果你想更严谨,也可以统一走 ethers.getAddress() 规范化。
5.4 nonce 没有一次性消费,存在重放风险
如果你只做了“验签正确就登录”,却没有处理 nonce,那么攻击者可能会复用同一份签名反复请求。
正确做法:
- nonce 服务端生成
- nonce 设置有效期
- 验签成功后立即消费
- 同一 nonce 不允许二次使用
这个坑我见过不少 Demo 都会漏,Demo 没关系,生产环境一定不能漏。
5.5 前端链 ID 和后端链 ID 不一致
比如用户当前钱包在 Polygon,但你服务端假定的是 Ethereum Mainnet。
这时即使签名本身合法,也应该拒绝这次登录,避免上下文混乱。
建议
- nonce 接口把允许的 chainId 范围返回给前端
- 前端先检查网络,不匹配时提示用户切换链
- 后端再次做最终校验,不信任前端
5.6 时间字段校验太松或太死
如果你完全不校验 issuedAt / expirationTime,签名会长期有效。
但如果校验过于严格,比如只允许 10 秒误差,也可能因为客户端时间漂移导致大量误判。
实战建议
expirationTime控制在 5~10 分钟issuedAt允许少量时间偏差- 服务端使用自己的时间作为最终裁判
5.7 钱包对 EIP-712 支持差异
大多数主流钱包支持 eth_signTypedData_v4,但不同钱包在 UI 展示、字段兼容性上会有差异。
建议
- 优先使用 ethers 的
signTypedData - 在测试环境至少覆盖 MetaMask、OKX Wallet、Rabby 这类主流钱包
- 尽量避免过于复杂的嵌套结构,登录消息保持扁平
六、安全/性能最佳实践
这一节我会把“能跑”和“能上生产”之间的差距讲清楚。
6.1 nonce 存 Redis,不要只放内存
本文为了演示用了 Map,但生产环境不够用:
- 服务重启会丢失 nonce
- 多实例部署无法共享
- 无法自然设置 TTL
- 不方便做消费状态管理
生产建议:
SETEX nonce:{address} value ttl- 验签成功后删除
- 或用事务保证“读取 + 消费”的原子性
6.2 登录成功后优先用 HttpOnly Cookie
JWT 放 localStorage 很方便,但有 XSS 风险。
如果你的站点是常规 Web 应用,通常更推荐:
- 登录成功后服务端
Set-Cookie HttpOnlySecureSameSite=Lax/Strict
如果你确实是前后端完全分离、跨域部署,才更常见用 Bearer Token。
一个实用原则
- 后台管理类、Web 产品:优先 Cookie Session
- API 网关、多端接入:可考虑 JWT,但要补好刷新机制和风控
6.3 域隔离必须严格
签名消息里最好明确这些字段:
domainurichainIdstatement
这相当于告诉用户:
你是在什么站点、为了什么目的、在哪条链环境下登录。
如果你省略这些信息,虽然也能做签名验证,但用户几乎无法判断自己到底签了什么。
6.4 限制签名有效期
一个实用配置:
- nonce 有效期:5~10 分钟
- 签名消息有效期:5~10 分钟
- 登录态有效期:2 小时 ~ 7 天,视业务风险而定
- 高风险操作再要求二次签名
注意:登录签名和交易签名不是一回事。
登录成功不代表用户授权你发交易,涉及资产操作时必须单独确认。
6.5 绑定业务上下文,避免“签名串用”
如果你的系统有多个子站点、多租户、多个环境,建议把环境信息纳入消息。
例如:
domain:app.example.comuri:https://app.example.comstatement:Sign in to Example Pro- 或增加
resources/tenantId字段
这样可以防止测试环境签名误用于生产环境,或 A 站点签名误用于 B 站点。
6.6 为高频登录做缓存和限流
性能层面,验签本身不算特别重,但高并发下仍要注意:
- nonce 接口限流
- 登录接口限流
- 对同地址短时间请求次数限制
- 用户资料读取缓存
- JWT 黑名单/撤销策略
可以参考下面这个状态流转:
stateDiagram-v2
[*] --> 未登录
未登录 --> 已获取Nonce: 请求 nonce
已获取Nonce --> 已签名待验证: 钱包签名成功
已签名待验证 --> 已登录: 验签通过 + 消费 nonce
已签名待验证 --> 未登录: 验签失败/过期
已登录 --> 已过期: token 失效
已过期 --> 未登录: 重新登录
6.7 不要把“地址存在”当成“用户可信”
钱包地址只能说明:
当前这个用户控制这个私钥。
它并不能说明:
- 这个用户不是机器人
- 这个地址没有被盗
- 这个地址没有风险行为
- 这个地址一定有某种业务权限
所以实际业务里,常见会叠加:
- 风控
- 黑名单
- 链上画像
- NFT / Token 持仓校验
- 多签名确认
- 二次交互确认
登录只是认证起点,不是完整信任体系。
七、方案补充:EIP-191 文本签名 vs EIP-712 结构化签名
很多团队会纠结:登录到底要不要上 EIP-712?
我的经验是这样:
适合继续用普通文本签名的情况
- 你在做 MVP
- 钱包兼容性优先
- 消息结构简单
- 后续不准备扩展复杂字段
更适合用 EIP-712 的情况
- 你希望签名内容结构清晰
- 你需要更强的上下文约束
- 你要对接多个前端/服务端
- 你希望后续扩展授权、委托、资源访问等场景
简单理解:
- EIP-191:上手快,但容易野生增长
- EIP-712:前期多一点约束,长期维护舒服很多
如果是中级以上项目,我一般更推荐 EIP-712。
八、一个更像生产环境的落地建议
如果你准备把这套方案真正上业务,可以按这个分层来做:
flowchart LR
A[前端 DApp] --> B[认证网关 Auth Service]
B --> C[Nonce/Session Redis]
B --> D[用户数据库]
B --> E[风控服务]
B --> F[业务 API]
A -->|EIP-712 签名| B
B -->|JWT/Cookie| A
推荐职责划分
前端
- 连接钱包
- 获取 nonce
- 发起签名
- 提交签名结果
- 保存登录态
认证服务
- 生成 nonce
- 统一校验 typed data
- 验签
- 签发 session/JWT
- 记录登录审计
业务服务
- 只关心认证后的用户身份
- 不重复实现签名验签逻辑
这样做有个好处:
你的登录能力会从某个 DApp 页面抽象成平台能力,后面接更多站点会省很多事。
九、总结
我们把一套完整的钱包签名登录方案走通了,核心就三件事:
- 服务端生成一次性 nonce
- 前端用 EIP-712 构造结构化登录消息并让钱包签名
- 服务端严格校验消息内容、时间、域、链和签名,再签发会话
如果你只记住几个最关键的落地点,我建议是:
- 登录消息一定带
nonce - nonce 一定一次性消费
- 前后端
domain/types/message必须完全一致 - 校验
chainId/domain/uri/expirationTime - 生产环境不要用内存存 nonce
- 有条件优先使用 HttpOnly Cookie 管理登录态
最后给一个很务实的边界建议:
- 如果你在做 Demo 或黑客松项目,先把最小链路跑通
- 如果你在做正式产品,至少把 nonce、过期时间、会话安全、限流、日志审计补齐
- 如果涉及资产操作,不要把“登录签名”误当成“交易授权”
登录是入口,安全是底线。
把这套方案做扎实,后面的链上身份、权限控制、资源授权都会顺很多。