要点:Sign-In with Ethereum(EIP-4361、ステータスは Final)は、決まったフィールドを持つプレーンテキストのメッセージで、personal_sign(EIP-191)で署名します。ウォレットはこのメッセージを表示し、domain が署名を求めているページと一致しなければ署名を拒否します。それ以外はすべてサーバーが行わなければなりません。ランダムな nonce を発行して 1 回だけ受け付け、domain、URI、Chain ID、Version と時刻のフィールドを自分の想定値と照合し、そのうえで署名を検証します。ecrecover を使ってよいのは通常のアカウントだけです。スマートコントラクトウォレットには、メッセージで指定されたチェーン上での ERC-1271 の isValidSignature が必要で、まだデプロイされていないウォレットは ERC-6492 のラッパーを付けて署名します。セッションは ENS 名や残高ではなく、アドレスに紐付けてください。
SIWE メッセージ
この記事のために viem で作成した SIWE メッセージです。
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 | いいえ | 人が読める 1 行の文。途中に改行は入れない |
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"、10 進数で表したバイト長、メッセージ本体を連結したものの keccak256 です。メッセージがテキストなので、EIP-712:実際に何に署名しているのか、その読み方で取り上げた 32 バイトのハッシュとは違い、ユーザーは自分が署名する内容を読むことができます。
誰が何をチェックするのか
EIP-4361 は役割を分担しています。ウォレットは、メッセージを ABNF に従って解析し、scheme と domain をリクエストの実際のオリジンと比較して一致しなければ拒否し、署名の前に domain、address、statement、resources を表示しなければなりません。これがフィッシング対策です。仕様に準拠したブラウザーウォレットからは、app-example.login 上のページが app.example と書かれたメッセージへの署名を得ることはできません。
リライングパーティは、メッセージが「ABNF に準拠しているか」をチェックし、「解析後の想定値」と照合し、署名を検証しなければなりません。コントラクトアカウントについては、「検証を行うコントラクト … を、指定された chain-id から解決」する必要があります。セッションは「アドレスに紐付けなければならない(MUST)」とされています。どれも自動では行われません。また、フィッシングページでも、自分自身のドメインを書いたメッセージへの署名なら集めることができます。だからこそ、サーバー側でもドメインをチェックする必要があります。
サーバー側の検証処理
以下の検証処理は、署名に手を付ける前にこれらのチェックを行い、暗号処理には viem の verifySiweMessage を使います。2 つの点は意図的な設計です。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 で署名されたメッセージを受け入れてしまいます。次の短いテストでは、これと合わせて 2 つ目の落とし穴である改行コードも確認します。
// 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"]
1 行目は、other.example 向けのメッセージに対する有効な署名が、ドメインを指定しない呼び出しで受け入れられたことを示しています。3 行目は、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
クライアント側
ログインを開始するページが行うことは 3 つです。サーバーから nonce を取得し、そのページ自身のドメインと URI でメッセージを組み立て、ウォレットに署名を求めます。ブラウザーのプロバイダーインターフェース(EIP-1193)では、この最後のステップは personal_sign リクエストになります。MetaMask の API 仕様では、そのパラメーターは 1 つ目が UTF-8 のメッセージを 16 進数でエンコードしたもの、2 つ目がアドレスで、pitfalls.mjs の出力の最後の行のとおりです。viem の signMessage などのライブラリはこのエンコードを行ってくれます。自前で書いたコードがアドレスを先に渡したり、16 進数ではなく生の文字列を渡したりして、誤ったバイト列に署名してしまうのはよくある失敗です。
そのあと、メッセージの組み立てに使ったフィールドではなく、署名したメッセージ文字列をそのままサーバーに送ります。サーバーはその文字列を解析してチェックします。個々のフィールドからサーバー側でメッセージを組み立て直すと、空白や順序のわずかな違いがことごとく検証の失敗につながり、さらに悪い場合には、チェックした内容と署名された内容が食い違います。
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(ステータスは Final)では、判断をウォレットに委ねます。検証者はウォレットの isValidSignature(bytes32 hash, bytes signature) を呼び出し、ウォレットは自身のルールで署名が有効と判断すれば 0x1626ba7e を返します。
多くのスマートウォレットは、最初のトランザクションの時点で初めてデプロイされます。しかしユーザーは、それより前にログインしたいと考えます。ERC-6492(ステータスは Final)は、ファクトリのアドレスと、ウォレットをデプロイするための calldata で署名を包み、末尾に 32 バイトのサフィックス 0x6492…6492 を付けます。このサフィックスを検出した検証者は、eth_call でデプロイをシミュレートしてから、デプロイされるはずのウォレットに問い合わせます。この ERC では、ERC-1271 よりも、また ecrecover よりも先にサフィックスの有無を確認することが求められています。viem の verifySiweMessage は、verifyHash を通じてこの 3 つをすべて処理します。
3 つの経路をすべて試すため、検証環境では Foundry 1.8.5(ghcr.io/foundry-rs/foundry:v1.8.5)の anvil でローカルチェーンを動かし、solc 0.8.30 でコンパイルした最小限のウォレットを用意しました。オーナーの鍵は 1 つで、isValidSignature は ecrecover で検証し、デプロイ前にアドレスがわかるように 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
この実行結果から、次のことがわかります。
- 3 種類のアカウントすべてが 1 つのコードパスでログイン:bob のウォレットでは、
ecrecoverはオーナーの鍵、つまりメッセージに書かれたものとは別のアドレスを返します。それをaddressと比較するサーバーはスマートウォレットのユーザーをすべて拒否し、代わりにオーナーを受け入れるサーバーはユーザーを誤ったアカウントでログインさせてしまいます。carol の未デプロイのウォレットでは、ecrecoverはラップされた署名を解析することすらできません。 - ERC-6492 の検証では何もデプロイされない:carol のウォレットには、ログインの前にも後にもコードがありませんでした。デプロイは、シミュレートされた呼び出しの中でだけ行われました。
- どの偽造も想定どおりのチェックで失敗:リプレイは nonce で、よく似たサイトはドメインで、別のチェーンはチェーン ID で、古いメッセージは
Issued Atで、書き換えたステートメントと誤った署名者は署名で失敗しました。
署名のあと:セッション
- セッションはアドレスとチェーンに紐付ける:EIP-4361 では、セッションは「
addressに紐付けなければならず(MUST)、変わりうるその先の解決済みリソースに紐付けてはならない」とされています。ENS 名やトークン残高がその例です。これらを使うときは、その都度チェックし直してください。 - コントラクトウォレットは判断を変えることがある:ERC-1271 の結果はチェーンの状態に依存します。オーナーは入れ替えられることがあり、セッション鍵は取り消されることがあります。EIP は、署名の裏付けとなる状態が変わったらセッションを無効にすることを提案しています。少なくとも、コントラクトアカウントのセッションは短くしてください。
- 独自のセッション用認証情報を発行する:検証が済んだら Cookie やトークンを発行し、以降のリクエストでは SIWE の署名そのものをベアラー型の認証情報として受け入れてはいけません。この署名は、すでに消費した nonce を除けば、有効期間内は仕様上リプレイできてしまいます。
- Expiration Time(有効期限)が制限するのはメッセージであり、セッションではない:これは署名を提示できる期限を決めるものです。セッションの有効期間は別に決める必要があります。
SIWE が適している場面
SIWE が認証するのはアカウントであり、人ではありません。SIWE が適しているのは、Ethereum アカウントそのものが意味のある ID である場合です。たとえば、ユーザーのオンチェーン資産を操作するアプリケーション、トークンの保有によるメンバーシップを確認するアプリケーション、メールアドレスなしでサービス間で同じ ID を使いたいアプリケーションです。一般的な Web サービスの唯一のログイン手段としては向いていません。鍵をなくしても「パスワードを忘れた場合」に当たる手段はなく、1 人のユーザーにアカウントが 1 つという保証もありません。また、EIP-4361 自身のセキュリティに関する考慮事項でも、1 つのアドレスをあらゆる場所で使い回すことのプライバシー上のコストが指摘されています。通常のアカウントであれば、パスキーを使うと、ウォレットなしでフィッシング耐性のあるログインを実現できます。ID 連携が必要なら、OpenID Connect によって検証済みの発行者とサブジェクトが得られます。多くのアプリケーションはこれらを組み合わせています。人にはパスキーまたは OIDC のセッションを使い、そこにアドレスを紐付けるために SIWE の署名を使います。
よくある間違い
- サーバーでメッセージを組み立て、クライアントがそれを変更せずに署名すると信用する:署名された内容をフィールドごとに検証してください。何を送り返すかを決めるのはクライアントです。
domainをHostヘッダーと比較する:プロキシの背後では、設定済みの公開ドメインを使ってください。- nonce を保存しない:発行済みの nonce の集合と一度も照合されない nonce では、何も防げません。
- スマートウォレットを誤ったチェーン上で検証する:同じアドレスでも、別のチェーンでは別のコントラクトであったり、コントラクトが存在しなかったりします。メッセージの
Chain IDのチェーン上で解決し、自分のサービスが対応していないチェーンは拒否してください。 - どこでも
ecrecoverを使う:スマートウォレットのユーザーを拒否してしまうか、さらに悪い場合には、そのログインをオーナーの鍵によるものと見なしてしまいます。 - 大文字と小文字を区別してアドレスを比較する:EIP-55 のチェックサムは大文字と小文字を混在させます。アドレスは大文字と小文字を区別せずに比較するか、正規化してから比較してください。
出典
- EIP-4361:Sign-In with Ethereum:メッセージフィールド、ABNF、リライングパーティとウォレットの手順、セキュリティに関する考慮事項
- EIP-191:署名付きデータの標準
- ERC-1271:コントラクト向けの標準署名検証メソッド
- ERC-6492:デプロイ前のコントラクトの署名検証
- ERC-5573:Sign-In with Ethereum のケイパビリティ(ReCaps)(ドラフト)
- MetaMask API 仕様:
personal_signのパラメーター - viem SIWE:
createSiweMessage、parseSiweMessage、verifySiweMessage - Foundry:anvil と forge