Sign-In with Ethereum(SIWE)实战:Node.js 后端验签与登录态设计
Sign-InwithEthereum(SIWE)实战
适用场景:DeFi / NFT / GameFi DApp 用户登录、白名单验证、后台权限控制
技术栈:Solidity DApp + Node.js + ethers.js / viem
一、为什么 DApp 需要 SIWE?
很多 Web3 项目的「登录」只是:
// ❌ 不安全:任何人都可以伪造
POST /api/login
{ "address": "0x1234..." }
这种方式的问题很明显:地址可以被伪造,后端无法确认「当前请求者真的拥有这个钱包的私钥」。
Sign-In with Ethereum(SIWE,EIP-4361) 解决的就是这个问题:
- 后端生成随机
nonce - 前端构造标准化签名消息
- 用户用钱包签名
- 后端验证签名是否由该地址发出
- 验证通过后建立登录态(Session / JWT)
一句话总结:
SIWE = Web3 世界的「密码登录」,但密码换成了钱包私钥签名。
二、整体流程
┌─────────┐ ┌─────────────┐ ┌──────────┐
│ 前端 │ │ Node.js API │ │ 钱包 │
└────┬────┘ └──────┬──────┘ └────┬─────┘
│ 1. GET /nonce │ │
│────────────────────>│ │
│ 2. 返回 nonce │ │
│<────────────────────│ │
│ 3. 构造 SIWE 消息 │ │
│────────────────────────────────────────────>│
│ 4. 用户签名 │ │
│<────────────────────────────────────────────│
│ 5. POST /verify │ │
│ (message + sig) │ │
│────────────────────>│ │
│ 6. 验签 + 发 token │ │
│<────────────────────│ │
三、SIWE 消息长什么样?
EIP-4361 规定了标准格式,例如:
example.com wants you to sign in with your Ethereum account:
0xAb5801a7D398351b8bE11C439e08C5B0959a9E2
Sign in to My DeFi DApp
URI: https://example.com
Version: 1
Chain ID: 1
Nonce: 8b5k2j9f3a1c
Issued At: 2026-06-21T08:00:00.000Z
Expiration Time: 2026-06-21T08:10:00.000Z
用户签的是这段可读文本,不是交易 raw data,体验比 personal_sign 乱签一段 hex 好很多。
四、项目依赖
npm install express siwe cors jsonwebtoken
npm install -D typescript @types/express @types/cors @types/jsonwebtoken
| 包 | 作用 |
|---|---|
siwe |
解析和验证 SIWE 消息(官方推荐) |
express |
HTTP API |
jsonwebtoken |
验签通过后发 JWT |
五、Node.js 后端完整实现
1. Nonce 存储(生产环境用 Redis)
开发阶段可以先用内存 Map,生产环境务必换 Redis,并设置 TTL。
// nonceStore.js
const nonceStore = new Map();
export function saveNonce(address, nonce) {
nonceStore.set(address.toLowerCase(), {
nonce,
createdAt: Date.now(),
});
}
export function consumeNonce(address, nonce) {
const key = address.toLowerCase();
const record = nonceStore.get(key);
if (!record) return false;
if (record.nonce !== nonce) return false;
// nonce 一次性使用
nonceStore.delete(key);
return true;
}
2. 生成 Nonce 接口
// server.js
import express from 'express';
import cors from 'cors';
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import { SiweMessage, generateNonce } from 'siwe';
import { saveNonce, consumeNonce } from './nonceStore.js';
const app = express();
app.use(cors({ origin: 'http://localhost:5173', credentials: true }));
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
const DOMAIN = 'localhost:3000';
const ORIGIN = 'http://localhost:5173';
/**
* Step 1: 前端传入钱包地址,后端返回 nonce
*/
app.get('/api/auth/nonce', (req, res) => {
const { address } = req.query;
if (!address || !/^0x[a-fA-F0-9]{40}$/.test(address)) {
return res.status(400).json({ error: 'Invalid address' });
}
const nonce = generateNonce();
saveNonce(address, nonce);
res.json({ nonce });
});
3. 验签接口(核心)
/**
* Step 2: 前端提交 message + signature,后端验签
*/
app.post('/api/auth/verify', async (req, res) => {
try {
const { message, signature } = req.body;
if (!message || !signature) {
return res.status(400).json({ error: 'Missing message or signature' });
}
// 解析 SIWE 消息
const siweMessage = new SiweMessage(message);
// 基础字段校验
if (siweMessage.domain !== DOMAIN) {
return res.status(401).json({ error: 'Invalid domain' });
}
if (siweMessage.uri !== ORIGIN) {
return res.status(401).json({ error: 'Invalid uri' });
}
// 校验 nonce 是否存在且一次性有效
const nonceValid = consumeNonce(siweMessage.address, siweMessage.nonce);
if (!nonceValid) {
return res.status(401).json({ error: 'Invalid or expired nonce' });
}
// 核心:验证签名
const result = await siweMessage.verify({ signature });
// result.data 包含 address, chainId 等
const address = result.data.address;
// 签发 JWT
const token = jwt.sign(
{
sub: address,
chainId: result.data.chainId,
},
JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({
success: true,
address,
token,
});
} catch (err) {
console.error('SIWE verify failed:', err);
res.status(401).json({ error: 'Signature verification failed' });
}
});
siweMessage.verify() 内部会:
- 恢复签名者地址
- 对比是否与 message 中的 address 一致
- 检查
expirationTime、notBefore等时间字段
4. JWT 鉴权中间件
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized' });
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, JWT_SECRET);
req.user = {
address: payload.sub,
chainId: payload.chainId,
};
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
}
// 受保护接口示例
app.get('/api/user/profile', authMiddleware, (req, res) => {
res.json({
address: req.user.address,
chainId: req.user.chainId,
});
});
app.listen(3000, () => {
console.log('SIWE auth server running on http://localhost:3000');
});
六、前端如何配合(简要)
以 ethers.js v6 + MetaMask 为例:
import { SiweMessage } from 'siwe';
import { BrowserProvider } from 'ethers';
async function signInWithEthereum() {
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const address = await signer.getAddress();
const network = await provider.getNetwork();
// 1. 获取 nonce
const nonceRes = await fetch(
`http://localhost:3000/api/auth/nonce?address=${address}`
);
const { nonce } = await nonceRes.json();
// 2. 构造 SIWE 消息
const message = new SiweMessage({
domain: 'localhost:3000',
address,
statement: 'Sign in to My DeFi DApp',
uri: 'http://localhost:5173',
version: '1',
chainId: Number(network.chainId),
nonce,
issuedAt: new Date().toISOString(),
expirationTime: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 分钟
});
const preparedMessage = message.prepareMessage();
// 3. 钱包签名
const signature = await signer.signMessage(preparedMessage);
// 4. 提交后端验签
const verifyRes = await fetch('http://localhost:3000/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: preparedMessage,
signature,
}),
});
const data = await verifyRes.json();
if (data.success) {
localStorage.setItem('token', data.token);
console.log('Login success:', data.address);
}
}
如果用 wagmi + ConnectKit / RainbowKit,也可以封装同样的流程,本质不变。
七、生产环境必须注意的 8 个安全点
1. Nonce 必须一次性
同一个 nonce 不能重复使用,否则攻击者可以重放签名。
2. 校验 domain 和 uri
防止钓鱼网站拿合法签名来你的后端登录。
if (siweMessage.domain !== process.env.APP_DOMAIN) {
throw new Error('domain mismatch');
}
3. 设置 expirationTime
建议 5~10 分钟,签名不是永久凭证。
4. HTTPS
生产环境前后端都必须 HTTPS,否则 token 和签名可能被截获。
5. JWT 不要存私钥相关信息
JWT payload 只存 address、chainId 等必要字段。
6. Logout 与 Token 失效
JWT 无状态,若要强制登出,需维护 token 黑名单(Redis)。
7. 不要混淆「登录」和「授权交易」
SIWE 只是证明钱包所有权,不等于用户授权了链上 approve 或 transfer。
8. 多链 DApp 要校验 chainId
GameFi / DeFi 跨链项目尤其要注意,避免用户在错误链上签名通过验证。
八、常见报错与排查
| 报错 | 原因 | 解决 |
|---|---|---|
Invalid or expired nonce |
nonce 过期或重复使用 | 重新 GET /nonce |
Signature verification failed |
签名与 message 不匹配 | 检查 prepareMessage 是否一致 |
Invalid domain |
前后端 domain 配置不一致 | 统一 domain 字符串 |
Invalid uri |
uri 与前端 origin 不一致 | 检查 ORIGIN 配置 |
| MetaMask 签完但后端 401 | message 被前端二次修改 | 原样传 preparedMessage 字符串 |
九、SIWE 在 DeFi / NFT / GameFi 里的典型用法
DeFi
- 登录查看个人持仓、收益历史
- 后台管理白名单(仅签名地址可访问 admin API)
NFT
- 登录后查看「我的 NFT」
- 结合后端索引,避免前端每次扫链
GameFi
- 钱包登录绑定游戏账号
- Electron / UniApp 客户端统一走 SIWE,替代传统账号密码
十、与传统 Web 登录的对比
| 方式 | 优点 | 缺点 |
|---|---|---|
| 仅传 address | 实现简单 | 可伪造,不安全 |
| SIWE 签名 | 标准、安全、钱包原生 | 需处理 nonce / 过期 |
| Email + 密码 | 用户熟悉 | 不符合 Web3 原生体验 |
| WalletConnect + SIWE | 支持移动端 | 实现稍复杂 |
对 DApp 来说,SIWE 是目前最主流、最合理的登录方案。
十一、可扩展架构(推荐)
Client (Web / Electron / UniApp)
↓
Auth API (/nonce, /verify)
↓
Redis (nonce / session blacklist)
↓
JWT
↓
Business API (DeFi/NFT/GameFi)
↓
DB / Indexer
登录成功后,业务 API 只认 JWT,不再重复验签,性能更好。
十二、总结
实现 SIWE 后端验证,核心就四步:
- 发 nonce — 防重放
- 收 message + signature — 标准 EIP-4361 格式
siweMessage.verify()— 验签- 发 JWT / Session — 建立登录态
作为全栈 DApp 开发者,SIWE 几乎是每个 DeFi / NFT / GameFi 项目的标配。合约负责资产,SIWE 负责身份,Node.js 后端负责把两者连起来——这也是完整 DApp 架构里非常关键的一环。
附录:最小可运行目录结构
siwe-auth-demo/
├── package.json
├── server.js # Express + SIWE 验签
├── nonceStore.js # nonce 存储
└── README.md