简短回答:以太坊登录(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 在第一次尝试时就被消耗,无论成功与否;链以服务器所用客户端连接的那条链为准,因此针对其他链的消息连签名检查这一步都到不了。
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 签名的消息。下面这个简短的测试证实了这一点,同时展示了第二个陷阱——换行符:
// 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]}"]`);
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 哈希,验证随之失败,看起来就像签名本身有问题。
// 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("")}`);
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 工厂合约,使钱包地址在部署前就能确定。
// 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 尚未部署的钱包登录,最后尝试重放和伪造:
// 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) }));
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 签名把某个地址关联到这个会话上。
常见错误
- 在服务器上构造消息,并相信客户端会原样签名。要逐个字段验证实际签名的内容;发回什么由客户端决定。
- 拿
domain与Host请求头比较。在代理后面时,应使用你配置的公开域名。 - 不保存 nonce。如果从不拿 nonce 与已下发的集合比对,它就什么也防不住。
- 在错误的链上验证智能钱包。同一个地址在另一条链上可能是另一个合约,也可能根本没有合约;应在消息的
Chain ID所指的链上解析,并拒绝你不提供服务的链。 - 处处使用
ecrecover。它会拒绝智能钱包用户,更糟的是,会把他们的登录记到所有者密钥名下。 - 区分大小写地比较地址。EIP-55 校验和混用大小写;比较地址时应不区分大小写,或先做规范化。
参考资料
- EIP-4361:以太坊登录(Sign-In with Ethereum):消息字段、ABNF、依赖方和钱包的处理步骤、安全考量
- EIP-191:签名数据标准
- ERC-1271:合约的标准签名验证方法
- ERC-6492:预部署合约的签名验证
- ERC-5573:以太坊登录能力授权(ReCaps)(草案)
- MetaMask API 规范:
personal_sign的参数 - viem SIWE:
createSiweMessage、parseSiweMessage、verifySiweMessage - Foundry:anvil 和 forge