跳到主要内容
ecdsa.com
← 全部文章

以太坊登录(SIWE,EIP-4361):在服务器端验证

对一条文本消息签名,能证明签名者控制着某个以太坊账户;但除非服务器自己检查,它并不能说明这是哪个网站、哪个会话或哪条链。本文介绍消息格式和一个服务器端验证器,并在本地链上用普通账户、已部署的智能钱包和尚不存在的钱包实际运行了一遍。

简短回答:以太坊登录(Sign-In with Ethereum,EIP-4361,已定稿)是一条字段固定的纯文本消息,用 personal_sign(EIP-191)签名。钱包会显示这条消息,如果其中的 domain 与发起请求的页面不一致,就拒绝签名。其余工作必须由服务器完成:生成随机 nonce 并且只接受一次,用自己的预期值核对 domain、URI、Chain ID、Version 和各个时间字段,然后验证签名。ecrecover 只适用于普通账户;智能合约钱包需要在消息所指定的链上调用 ERC-1271 的 isValidSignature,尚未部署的钱包则用 ERC-6492 包装格式签名。会话应绑定到地址,而不是 ENS 名称或余额。

登录消息

下面是一条 SIWE 消息,由 viem 为本文生成:

钱包显示的消息(取自下文的运行)
app.example wants you to sign in with your Ethereum account:
0x701111bB8ee63D09066471B2b26E8a6eD017B521

Sign in to app.example.

URI: https://app.example/login
Version: 1
Chain ID: 31337
Nonce: 7d19b07372ad88a030c953465f770a48
Issued At: 2026-10-20T13:10:41.396Z
Expiration Time: 2026-10-20T13:20:41.396Z

格式由 EIP 中的 ABNF 语法规定,每一行都有各自的作用(§ Message Fields):

字段必填作用
scheme(位于域名之前,可选)否源的协议;省略时为 HTTPS
domain是请求登录的源;钱包会将它与当前页面比对
address是签名的账户,采用 EIP-55 校验和格式
statement否一行供人阅读的文本,中间不能换行
URI是此次登录所针对的资源
Version是必须为 1
Chain ID是会话所绑定的链,合约账户必须在这条链上解析
Nonce是随机值,至少 8 个字母或数字,用于防重放
Issued At是创建时间,RFC 3339 格式
Expiration Time、Not Before否已签名消息的有效时间窗口
Request ID、Resources否应用自己的标识符,以及用户授权访问的 URI

钱包用 personal_sign 对它签名,也就是 EIP-191 的 0x45 版本:对 "\x19Ethereum Signed Message:\n"、十进制表示的字节长度和消息本身计算 keccak256。由于消息是文本,用户能读懂自己签的是什么,这一点与 EIP-712:你实际签署的是什么一文中讨论的 32 字节哈希不同。

谁负责检查什么

EIP-4361 对工作做了分工。钱包必须按 ABNF 解析消息,将 scheme 和 domain 与请求的实际源比对,不一致就拒绝,并在签名前显示 domain、address、statement 和 resources。这就是防钓鱼机制:位于 app-example.login 的页面,无法让符合规范的浏览器钱包对一条写着 app.example 的消息签名。

依赖方必须检查消息是否“符合 ABNF”、是否与“解析后的预期值”一致,并验证签名;对于合约账户,“执行验证的合约……根据指定的链 ID 解析”。会话“必须(MUST)绑定到地址”。这些都不会自动发生,而且钓鱼页面仍然可以让用户签署一条写着它自己域名的消息,所以服务器也必须检查域名。

服务器端验证器

下面的验证器在处理签名之前先完成这些检查,密码学部分则交给 viem 的 verifySiweMessage。有两处设计是有意为之的:nonce 在第一次尝试时就被消耗,无论成功与否;链以服务器所用客户端连接的那条链为准,因此针对其他链的消息连签名检查这一步都到不了。

siwe-server.mjs(viem 2.57.7、Node.js 24.22)
import { randomBytes } from "node:crypto";
import { parseSiweMessage } from "viem/siwe";

/**
 * Server side of Sign-In with Ethereum (EIP-4361). `client` is a viem public client connected
 * to the chain the sessions are bound to: EIP-4361 requires contract accounts to be resolved
 * on the message's Chain ID.
 */
export function createSiweVerifier({ client, domain, origin, maxAgeSeconds = 300 }) {
  const pending = new Map(); // nonce -> expiry (ms); one entry per sign-in attempt

  return {
    /** Step 1: a fresh nonce for the page that is about to ask the wallet to sign. */
    issueNonce() {
      const nonce = randomBytes(16).toString("hex"); // 128 bits from the OS CSPRNG, 32 alphanumeric characters
      pending.set(nonce, Date.now() + maxAgeSeconds * 1000);
      return nonce;
    },

    /** Step 2: check the signed message, then the signature; returns the session subject. */
    async verify(message, signature) {
      const fields = parseSiweMessage(message);
      const expiry = pending.get(fields.nonce);
      pending.delete(fields.nonce); // single use, whatever the outcome
      if (expiry === undefined) throw new Error("unknown or already used nonce");
      if (Date.now() > expiry) throw new Error("nonce expired");
      if (fields.domain !== domain) throw new Error(`message is for ${fields.domain}`);
      if (!fields.uri || new URL(fields.uri).origin !== origin) throw new Error(`URI ${fields.uri} is not on ${origin}`);
      if (fields.version !== "1") throw new Error(`unsupported version ${fields.version}`);
      if (fields.chainId !== client.chain.id) throw new Error(`message is for chain ${fields.chainId}`);
      if (!fields.issuedAt || Date.now() - fields.issuedAt.getTime() > maxAgeSeconds * 1000) {
        throw new Error("issued too long ago");
      }
      // verifySiweMessage checks domain and nonce only when they are passed, plus expiration and
      // not-before, then the signature: ecrecover for plain accounts, ERC-1271 for deployed
      // contract accounts, ERC-6492 for contract accounts that are not deployed yet.
      const valid = await client.verifySiweMessage({ message, signature, domain, nonce: fields.nonce });
      if (!valid) throw new Error("signature does not verify");
      return { address: fields.address, chainId: fields.chainId }; // bind the session to the address
    },
  };
}

要注意 viem 检查什么、不检查什么。在 2.57.7 中,verifySiweMessage 只有在你传入 domain、nonce 和 scheme 时才会比较它们,会用当前时间检查 Expiration Time 和 Not Before,然后验证签名。它不看 Chain ID、URI、Version,也不看 Issued At 是多久以前。只传 message 和 signature 调用它,就会接受为任意域名、带任意 nonce 签名的消息。下面这个简短的测试证实了这一点,同时展示了第二个陷阱——换行符:

pitfalls.mjs
// Two ways a SIWE check goes wrong without any attacker skill: calling the library without the
// expected values, and changing the line endings of the message. EOA only; keys generated here.
import { createPublicClient, http } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { foundry } from "viem/chains";
import { createSiweMessage } from "viem/siwe";

const client = createPublicClient({ chain: foundry, transport: http("http://127.0.0.1:8545") });
const alice = privateKeyToAccount(generatePrivateKey());

// A message alice signed on some other site, with that site's nonce.
const elsewhere = createSiweMessage({
  domain: "other.example", address: alice.address, uri: "https://other.example/", version: "1",
  chainId: foundry.id, nonce: "a1b2c3d4", issuedAt: new Date(),
});
const signature = await alice.signMessage({ message: elsewhere });
console.log(`verifySiweMessage({ message, signature })                 -> ${await client.verifySiweMessage({ message: elsewhere, signature })}`);
console.log(`verifySiweMessage({ message, signature, domain, nonce }) -> ${await client.verifySiweMessage({ message: elsewhere, signature, domain: "app.example", nonce: "f00dfeed" })}`);

// The same message after a transport or template turned LF into CRLF.
const crlf = elsewhere.replaceAll("\n", "\r\n");
console.log(`same message with CRLF line endings                      -> ${await client.verifySiweMessage({ message: crlf, signature })}`);

// What personal_sign receives over EIP-1193: the hex-encoded UTF-8 message first, then the address.
const params = [`0x${Buffer.from(elsewhere, "utf8").toString("hex")}`, alice.address];
console.log(`personal_sign params: ["${params[0].slice(0, 26)}...", "${params[1]}"]`);
输出(node:24.22.0-bookworm-slim,连接 anvil 1.8.5)
verifySiweMessage({ message, signature })                 -> true
verifySiweMessage({ message, signature, domain, nonce }) -> false
same message with CRLF line endings                      -> false
personal_sign params: ["0x6f746865722e6578616d706c...", "0xC527ED169430Ca38aADCEb03cd04f6F390b4840F"]

第一行表明:一条针对 other.example 的消息带着有效签名,被一个没有指定域名的调用接受了。第三行说明了为什么 SIWE 消息必须逐字节原样传输:ABNF 规定行与行之间用 LF 分隔,模板、表单字段或 HTTP 层一旦把它改写成 CRLF,就会得到不同的 EIP-191 哈希,验证随之失败,看起来就像签名本身有问题。

nonce-check.mjs
// How related are two consecutive nonces from viem's generateSiweNonce()?
import { generateSiweNonce } from "viem/siwe";

const a = generateSiweNonce();
const b = generateSiweNonce();
console.log(`first   ${a}`);
console.log(`second  ${b}`);
console.log(`second equals first shifted by one character: ${b.slice(0, 95) === a.slice(1)}`);
console.log(`alphabet: ${[...new Set(a + b)].sort().join("")}`);
输出(node:24.22.0-bookworm-slim)
first   ef56353692864c3d4da72cbdea645d3aac4543c9527e869572a71cbd5bf1d340b2488493e175223f8453b8e6e4afae8b
second  f56353692864c3d4da72cbdea645d3aac4543c9527e869572a71cbd5bf1d340b2488493e175223f8453b8e6e4afae8b7
second equals first shifted by one character: true
alphabet: 0123456789abcdef

客户端

发起登录的页面要做三件事:从服务器获取 nonce,用页面自己的域名和 URI 构造消息,然后请求钱包签名。通过浏览器的提供者接口(EIP-1193),最后一步是一个 personal_sign 请求;按照 MetaMask 的 API 规范,它的参数依次是十六进制编码的 UTF-8 消息和地址,正如 pitfalls.mjs 输出的最后一行所示。viem 的 signMessage 这类库会完成编码;手写代码如果把地址放在前面,或者传入原始字符串而不是十六进制,就常常会对错误的字节签名。

然后把签名时所用的那条消息字符串原样发给服务器,而不是发送用来构造它的各个字段。服务器解析并检查的就是这个字符串;如果在服务器上用分散的字段重新拼出消息,任何空白或顺序上的差异都可能导致验证失败,更糟的是,可能导致检查的内容与签名的内容不一致。

Resources 列表值得单独说一句。ERC-5573(“ReCaps”,仍为草案)用列表的最后一项——一个 urn:recap: URI——请求限定范围的能力授权,并在声明语句中追加一句自动生成的话(“I further authorize the stated URI to perform the following actions on my behalf…”)。如果你使用它,服务器就必须解析并强制执行这些授权;普通的 SIWE 验证器会忽略它们。

智能合约钱包:ERC-1271 与 ERC-6492

智能合约钱包(例如 Safe 或 ERC-4337 账户)没有属于自己的私钥。对它的签名执行 ecrecover,得到的是实际签名的那个密钥的地址(某个所有者或会话密钥),或者什么也得不到,但绝不会是钱包的地址。ERC-1271(已定稿)把判断交给钱包:验证器调用钱包上的 isValidSignature(bytes32 hash, bytes signature),如果按钱包自己的规则签名有效,它就返回 0x1626ba7e。

许多智能钱包采用延迟部署,在第一笔交易时才上链,而用户希望在那之前就能登录。ERC-6492(已定稿)把签名与工厂合约地址以及部署该钱包所需的 calldata 包装在一起,末尾附加 32 字节的后缀 0x6492…6492。验证器看到这个后缀后,会在 eth_call 中模拟部署,然后询问这个尚未部署的钱包;该 ERC 要求先检查后缀,再做 ERC-1271 检查和 ecrecover。viem 的 verifySiweMessage 通过 verifyHash 完成这三种验证。

为了测试这三条路径,实验用 Foundry 1.8.5 的 anvil(ghcr.io/foundry-rs/foundry:v1.8.5)运行了一条本地链,并用 solc 0.8.30 编译了一个最小的钱包合约:一个所有者密钥,用 ecrecover 实现的 isValidSignature,以及一个 CREATE2 工厂合约,使钱包地址在部署前就能确定。

OwnerWallet.sol(用 forge 1.8.5、solc 0.8.30 编译)
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

/// A minimal smart-contract account for the article's lab: one owner key, ERC-1271 signature checks.
contract OwnerWallet {
    address public immutable owner;

    constructor(address owner_) {
        require(owner_ != address(0), "owner required");
        owner = owner_;
    }

    /// ERC-1271: returns 0x1626ba7e when `signature` is the owner's signature over `hash`.
    function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4) {
        if (signature.length != 65) return 0xffffffff;
        bytes32 r = bytes32(signature[0:32]);
        bytes32 s = bytes32(signature[32:64]);
        uint8 v = uint8(signature[64]);
        // Reject high-s values (EIP-2), so one owner signature has exactly one valid encoding.
        if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) return 0xffffffff;
        return ecrecover(hash, v, r, s) == owner ? bytes4(0x1626ba7e) : bytes4(0xffffffff);
    }
}

/// Deploys OwnerWallet with CREATE2, so a wallet's address is known before it exists (ERC-6492).
contract OwnerWalletFactory {
    function deploy(address owner, bytes32 salt) external returns (address wallet) {
        wallet = predict(owner, salt);
        if (wallet.code.length == 0) new OwnerWallet{salt: salt}(owner);
    }

    function predict(address owner, bytes32 salt) public view returns (address) {
        bytes32 initCodeHash = keccak256(abi.encodePacked(type(OwnerWallet).creationCode, abi.encode(owner)));
        return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, initCodeHash)))));
    }
}

驱动脚本先给新生成的部署者密钥充值,部署工厂合约,部署 bob 的钱包,而 carol 的钱包只预测地址;接着分别以 alice(普通账户)、bob 的钱包和 carol 尚未部署的钱包登录,最后尝试重放和伪造:

run.mjs
// Lab driver: an EOA, a deployed ERC-1271 wallet and an undeployed (ERC-6492) wallet sign in to the
// same server on a local anvil chain; then the attacks the server has to refuse. All keys are generated here.
import { readFileSync } from "node:fs";
import { createPublicClient, createTestClient, createWalletClient, encodeFunctionData, hashMessage, http, parseEther, recoverMessageAddress, serializeErc6492Signature } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { foundry } from "viem/chains";
import { createSiweMessage } from "viem/siwe";
import { createSiweVerifier } from "./siwe-server.mjs";

const transport = http("http://127.0.0.1:8545");
const client = createPublicClient({ chain: foundry, transport });
const deployer = privateKeyToAccount(generatePrivateKey());
await createTestClient({ chain: foundry, mode: "anvil", transport }).setBalance({ address: deployer.address, value: parseEther("10") });
const wallet = createWalletClient({ account: deployer, chain: foundry, transport });

const artifact = (name) => JSON.parse(readFileSync(`/chain/out/OwnerWallet.sol/${name}.json`, "utf8"));
const { abi: factoryAbi, bytecode } = artifact("OwnerWalletFactory");
const deployTx = await wallet.deployContract({ abi: factoryAbi, bytecode: bytecode.object });
const factory = (await client.waitForTransactionReceipt({ hash: deployTx })).contractAddress;
const predict = (owner, salt) => client.readContract({ address: factory, abi: factoryAbi, functionName: "predict", args: [owner, salt] });
const deployWallet = async (owner, salt) => {
  const hash = await wallet.writeContract({ address: factory, abi: factoryAbi, functionName: "deploy", args: [owner, salt] });
  await client.waitForTransactionReceipt({ hash });
};

const server = createSiweVerifier({ client, domain: "app.example", origin: "https://app.example" });
const messageFor = (address, overrides = {}) =>
  createSiweMessage({
    domain: "app.example",
    address,
    statement: "Sign in to app.example.",
    uri: "https://app.example/login",
    version: "1",
    chainId: foundry.id,
    nonce: server.issueNonce(),
    issuedAt: new Date(),
    expirationTime: new Date(Date.now() + 10 * 60_000),
    ...overrides,
  });

// Three users. alice: a plain account. bob: a deployed smart wallet. carol: a smart wallet not deployed yet.
const alice = privateKeyToAccount(generatePrivateKey());
const bob = privateKeyToAccount(generatePrivateKey());
const carol = privateKeyToAccount(generatePrivateKey());
const salt = `0x${"00".repeat(31)}01`;
await deployWallet(bob.address, salt);
const bobWallet = await predict(bob.address, salt);
const carolWallet = await predict(carol.address, salt);

const aliceMessage = messageFor(alice.address);
console.log(`${aliceMessage}\n`);

const attempts = [];
attempts.push(["EOA, personal_sign", aliceMessage, await alice.signMessage({ message: aliceMessage })]);
const bobMessage = messageFor(bobWallet);
attempts.push(["deployed wallet, ERC-1271", bobMessage, await bob.sign({ hash: hashMessage(bobMessage) })]);
const carolMessage = messageFor(carolWallet);
attempts.push([
  "undeployed wallet, ERC-6492",
  carolMessage,
  serializeErc6492Signature({
    address: factory,
    data: encodeFunctionData({ abi: factoryAbi, functionName: "deploy", args: [carol.address, salt] }),
    signature: await carol.sign({ hash: hashMessage(carolMessage) }),
  }),
]);
console.log(`carol's wallet has code before sign-in: ${Boolean(await client.getCode({ address: carolWallet }))}`);

for (const [label, message, signature] of attempts) {
  let recovered;
  try {
    recovered = await recoverMessageAddress({ message, signature });
  } catch (e) {
    recovered = e.shortMessage ?? e.message;
  }
  const claimed = message.split("\n")[1];
  const result = await server.verify(message, signature).then((s) => `session for ${s.address.slice(0, 10)}...`, (e) => `rejected: ${e.message}`);
  console.log(`${label.padEnd(28)} ${result}; ecrecover alone gives ${recovered === claimed ? "the same address" : String(recovered).slice(0, 42)}`);
}
console.log(`carol's wallet has code after sign-in:  ${Boolean(await client.getCode({ address: carolWallet }))}`);

// What the server must refuse.
const check = async (label, message, signature) => {
  const result = await server.verify(message, signature).then(() => "ACCEPTED", (e) => `rejected: ${e.message}`);
  console.log(`${label.padEnd(36)} ${result}`);
};
const replayed = messageFor(alice.address);
const replayedSig = await alice.signMessage({ message: replayed });
await server.verify(replayed, replayedSig);
await check("same message and signature again", replayed, replayedSig);
const phish = messageFor(alice.address, { domain: "app-example.login", uri: "https://app-example.login/" });
await check("signed on a look-alike site", phish, await alice.signMessage({ message: phish }));
const otherChain = messageFor(alice.address, { chainId: 1 });
await check("message for chain 1", otherChain, await alice.signMessage({ message: otherChain }));
const stale = messageFor(alice.address, { issuedAt: new Date(Date.now() - 3600_000), expirationTime: new Date(Date.now() + 3600_000) });
await check("issued an hour ago", stale, await alice.signMessage({ message: stale }));
const original = messageFor(alice.address);
const originalSig = await alice.signMessage({ message: original });
await check("statement edited after signing", original.replace("Sign in to app.example.", "Sign in to app.example as admin."), originalSig);
const mallory = privateKeyToAccount(generatePrivateKey());
const claimsBob = messageFor(bobWallet);
await check("bob's wallet, signed by someone else", claimsBob, await mallory.sign({ hash: hashMessage(claimsBob) }));
输出(node:24.22.0-bookworm-slim,连接 anvil 1.8.5)
carol's wallet has code before sign-in: false
EOA, personal_sign           session for 0x701111bB...; ecrecover alone gives the same address
deployed wallet, ERC-1271    session for 0x5d3a68A0...; ecrecover alone gives 0xE4e0d879c3a051245cc489dE8Ae281c019691864
undeployed wallet, ERC-6492  session for 0x6F47E5F9...; ecrecover alone gives invalid signature length
carol's wallet has code after sign-in:  false
same message and signature again     rejected: unknown or already used nonce
signed on a look-alike site          rejected: message is for app-example.login
message for chain 1                  rejected: message is for chain 1
issued an hour ago                   rejected: issued too long ago
statement edited after signing       rejected: signature does not verify
bob's wallet, signed by someone else rejected: signature does not verify

运行结果说明了以下几点:

  • 三种账户都通过同一条代码路径完成了登录。对于 bob 的钱包,ecrecover 返回的是所有者密钥对应的地址,与消息中的地址不同;服务器如果拿它与 address 比较,就会拒绝所有智能钱包用户,而如果改为接受所有者,就会让用户登录到错误的账户。对于 carol 尚未部署的钱包,ecrecover 甚至无法解析包装后的签名。
  • ERC-6492 验证不会部署任何东西。登录前后,carol 的钱包都没有代码;部署只发生在模拟调用内部。
  • 每一次伪造都在对应的检查处失败:重放在 nonce 处被拦下,仿冒网站在域名处,另一条链在链 ID 处,过时的消息在 Issued At 处,被篡改的声明语句和错误的签名者在签名处。

签名之后:会话

  • 将会话绑定到地址和链。EIP-4361 规定,会话“必须(MUST)绑定到 address,而不是绑定到进一步解析出、可能发生变化的资源”,例如 ENS 名称或代币余额。使用这些信息时要重新检查。
  • 合约钱包可能“改变主意”。ERC-1271 的结果取决于链上状态:所有者可以更换,会话密钥可以被撤销。该 EIP 建议在签名所依赖的状态发生变化时让会话失效;至少应让合约账户的会话保持较短的有效期。
  • 验证通过后签发你自己的会话凭证(cookie 或令牌),后续请求中切勿把 SIWE 签名本身当作持有者凭证接受:按照设计,它在有效期窗口内可以被重放,唯一的例外是你已经消耗掉的那个 nonce。
  • 过期时间(Expiration Time)限制的是消息,而不是会话。它限定签名可以被出示的时间范围;会话的有效期需要你另行决定。

什么时候适合用 SIWE

SIWE 验证的是账户的身份,而不是人的身份。当以太坊账户本身就是你所关心的身份时,它很合适:应用要操作用户的链上资产、按代币持有情况检查会员资格,或者希望不借助电子邮件地址就能在多个服务之间使用同一身份。作为通用 Web 服务的唯一登录方式,它就不太合适了:密钥丢失后没有“忘记密码”可用,无法保证每个用户只有一个账户,而且 EIP-4361 自己的安全考量一节也指出,到处复用同一个地址会付出隐私代价。对于常规的用户账号,通行密钥(passkey)无需钱包就能提供抗钓鱼的登录;对于联合身份,OpenID Connect 能提供经过验证的颁发者和主体。许多应用会把两者结合起来:用通行密钥或 OIDC 会话代表这个人,再用 SIWE 签名把某个地址关联到这个会话上。

常见错误

  1. 在服务器上构造消息,并相信客户端会原样签名。要逐个字段验证实际签名的内容;发回什么由客户端决定。
  2. 拿 domain 与 Host 请求头比较。在代理后面时,应使用你配置的公开域名。
  3. 不保存 nonce。如果从不拿 nonce 与已下发的集合比对,它就什么也防不住。
  4. 在错误的链上验证智能钱包。同一个地址在另一条链上可能是另一个合约,也可能根本没有合约;应在消息的 Chain ID 所指的链上解析,并拒绝你不提供服务的链。
  5. 处处使用 ecrecover。它会拒绝智能钱包用户,更糟的是,会把他们的登录记到所有者密钥名下。
  6. 区分大小写地比较地址。EIP-55 校验和混用大小写;比较地址时应不区分大小写,或先做规范化。

参考资料