Sign-In with Ethereum(SIWE)实战:Node.js 后端验签与登录态设计

合约开发 发布于 2026-06-21 阅读 120

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) 解决的就是这个问题:

  1. 后端生成随机 nonce
  2. 前端构造标准化签名消息
  3. 用户用钱包签名
  4. 后端验证签名是否由该地址发出
  5. 验证通过后建立登录态(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 一致
  • 检查 expirationTimenotBefore 等时间字段

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 只存 addresschainId 等必要字段。

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 后端验证,核心就四步:

  1. 发 nonce — 防重放
  2. 收 message + signature — 标准 EIP-4361 格式
  3. siweMessage.verify() — 验签
  4. 发 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
该文章收录于
合约对接(前端/node.js)
11 订阅 8 篇内容

0 条评论