3MIKAN
仮想通貨直コン

SIWEを安全に実装する:nonce・domain・URI・chain IDとsession設計

Sign-In with Ethereumを署名確認だけで終わらせず、ERC-4361 field、single-use nonce、EOA・ERC-1271、cookie、CSRF、logoutまでlocal再現で検証します。

3MIKANのブランドキャラクターが署名カードをnonce・domain・URI・chain IDの検証ゲートへ通し、保護されたsession keyを発行するSIWEの記事画像

SIWE(Sign-In with Ethereum)を安全に実装する中心は、署名の真偽だけでなく「このsiteが、今、1回だけ発行したlogin challengeか」を検証することです。ERC-4361 messageを厳密に解析し、serverが固定したschemedomainURIchain ID、時刻と照合し、nonceを1回だけ消費してからEOAまたはERC-1271署名を確認します。

成功後も終わりではありません。sessionをaddressへ結び付け、cookie、CSRF、logout、walletのaddress / chain変更、contract accountのstate変更まで設計して、ようやくweb authenticationの一連の流れになります。

SIWEはmessage署名からweb sessionを作る規則

ERC-4361は、Ethereum accountでoff-chain serviceへ認証し、sessionを確立するための標準です。EOAでは人が読めるmessageをERC-191形式で署名し、serverはmessageとsignatureの両方を受け取ります。

ここで確認する対象は2層あります。

確認するもの 確認しないまま成功にすると起きること
challenge policy 誰が、何のURIで、どのchainへ、いつ、どのnonceを発行したか 別site・別request・過去signatureの使い回し
signer proof messageに書かれたaddressが本当に署名したか 別key・別contract stateのsignatureを受理

signatureがvalidでも、challenge policyが違えばloginは失敗です。反対に、fieldが正しくてもsignatureが違えばsessionを発行しません。

ERC-4361 fieldを役割ごとに読む

ERC-4361のABNFは、fieldの名前だけでなく改行、順序、文字種を定義します。独自のsplit("\n")で部分的に読むのではなく、準拠parserで全体を解析してからapplicationの期待値を比較します。

field 必須 意味 serverでの扱い
scheme optional request originのscheme 未指定時の既定はHTTPS。公開環境では期待schemeを固定する
domain required 署名を要求するauthority。hostと任意portを含む 設定済みdomainと完全一致させる
address required 認証するEthereum address signatureの期待signer、sessionのbinding先にする
statement optional 人が読む署名目的 改行を入れず、認証以上の同意を曖昧に混ぜない
URI required signing subjectとなるresource login endpointなど、serverで許可したURIと一致させる
version required ERC-4361 version 現行は正確に1
chain ID required sessionを結ぶchain。contract accountを解決するchain allowlistした値と、そのchain専用clientへ対応させる
nonce required replayを防ぐchallenge 8文字以上の英数字。高entropy、短期限、1回限りにする
issued-at required message発行時刻 server clockと許容skewを決める
expiration-time optional message失効時刻 この実装では必須policyにして5分後へ固定する
not-before optional 使用開始時刻 未来なら開始前として拒否する
request-id optional application固有request ID logやtrace用途を決め、権限として推測しない
resources optional 関連URI一覧 解釈はERC-4361の範囲外。列挙だけで認可しない

domainURIは同じ文字列を重複して置くfieldではありません。前者は署名要求元のauthority、後者は署名対象resourceです。chain IDも「現在walletで選ばれているchainを何となく記録する値」ではなく、特にERC-1271 contractをどのchainで解決するかを決めます。

expected originをHost headerから作らない

expected valueは、受信requestから自由に組み立てずserver設定へ置きます。たとえば公開URLがhttps://login.exampleなら、scheme=httpsdomain=login.exampleURI=https://login.example/auth/siweをdeployment configへ固定します。

reverse proxyの背後でも、HostX-Forwarded-Hostを無条件に信用しません。公開sampleのresolveRequestOrigin()は次の順で扱います。

  1. 通常はcanonical originを使う
  2. forwarded headerがあれば、接続元peerがtrusted proxy allowlist内か確認する
  3. protohostは単一値だけを受け取る
  4. 復元したoriginが最終allowlist内かもう一度確認する

これにより、攻撃者がHost: evil.exampleを送り、その値をSIWE messageへ反映させる経路を閉じます。wallet側のorigin表示・検証と、server側のexpected-field検証は両方必要です。

nonceは生成・期限・消費を一つのstateとして扱う

ERC-4361はnonceを8文字以上の英数字と定義しますが、具体的な生成・保存方法までは決めません。公開sampleではNode.js crypto.randomBytes(16)の128-bit値をhexへ変換し、保存時はSHA-256 hashをkeyにして5分のTTLを持たせます。

重要なのは「照合してから後で削除」ではなく、未使用から使用済みへの変更を1回の原子的操作にすることです。

take(nonce, now) {
  const key = sha256(nonce);
  const record = records.get(key);
  if (!record) throw new AuthError('unknown-or-used-nonce');

  records.delete(key); // async signature verificationより前に消費
  if (now > record.expiresAt) throw new AuthError('expired-nonce');
  return record;
}

これは1つのNode.js process内を説明するsampleです。複数instanceで動かす場合は、shared databaseで条件付きDELETEやcompare-and-setを1 transactionとして実行します。先にSELECTして、署名検証後に別requestでDELETEすると、同時に届いた2件が両方成功する時間差が残ります。

signatureがinvalidだった場合もnonceは復活させません。攻撃者が失敗を繰り返せるchallengeを残すより、clientへ新しいchallengeの取得を求める方がstateは単純です。

SIWE messageを解析し、期待fieldとsingle-use nonceを確認してからEOAまたはERC-1271署名検証とsession発行へ進む順序

検証順は解析、field、nonce、signature、session

backendは次の順序を固定します。

  1. message sizeを制限し、ERC-4361 ABNFとして全体を解析する
  2. schemedomainURIversionchain IDをserver policyと比較する
  3. issued-atのclock skew、expiration-timenot-beforeをserver clockで確認する
  4. nonceを期限内・未使用から使用済みへ原子的に移す
  5. parsed addressを期待signerとしてEOAまたはERC-1271 signatureを検証する
  6. freshなsession IDを作り、address、chain ID、signer typeへ結び付ける

fieldと時刻は署名検証より安いので先に落とせます。ただしnonce consumeは非同期のRPC / signature処理より前です。同じnonceの並行requestが来ても、2件目はunknown-or-used-nonceになり、sessionを2つ作れません。

EOAとERC-1271を同じ入口で検証する

EOAはERC-191 message hashからsignerをrecoverします。deployed contract accountは、messageのchain IDに対応するclientでcodeを読み、ERC-1271isValidSignature結果を確認します。

公開sampleはviem 2.56.0publicClient.verifyMessageを使います。

const code = (await publicClient.getCode({ address })) ?? '0x';
const signerType = code === '0x' ? 'eoa' : 'erc1271';

const valid = await publicClient.verifyMessage({
  address,
  message: originalSiweMessage,
  signature,
});

verifyMessageへ到達する前に、applicationがchain clientとexpected fieldsを決めていることが前提です。「libraryがtrueを返したから、どのdomain・URI・nonceでもよい」にはなりません。

ERC-1271の署名検証で説明した通り、contract signatureは現在stateに依存します。owner、threshold、module、pause状態が変われば、同じmessageとsignatureでも結果が変わり得ます。

SIWEはEIP-712のdomain separatorへ置き換える形式ではなく、現行ERC-4361はERC-191の人が読めるmessageを使います。また、この記事のlocal再現はdeployed ERC-1271までです。未デプロイsmart accountを扱う場合は、ERC-6492 wrapperとtrusted factory policyを別途reviewします。

local再現で成功系と20の拒否理由を固定した

公開再現データは、Node.js 24.x、npm 11.9.0、siwe 3.0.0、ethers peer 6.17.0、viem 2.56.0、Foundry / Anvil 1.8.0、Solidity 0.8.36、OpenZeppelin Contracts 5.6.1、local chain ID 31337へ固定しています。public RPC、実在wallet、個人key、署名prompt、実資産は使っていません。

success case result 確認したstate
EOA ERC-191 true parsed addressとsession addressが一致
deployed ERC-1271 true local chain上のcontract accountで検証
同一nonceの並行2要求 成功1、拒否1 atomic consumeでsession成功を1件に限定
trusted proxy経由 true peerと最終originの両allowlistを通過
Origin + CSRF true address-bound sessionでstate changeを許可
logout revoked server sessionを削除
address / chain change revoked wallet contextとsession bindingの不一致
ERC-1271 owner削除後 signature false matching contract sessionを1件失効

ERC-1271 contract自体のFoundry 14 testsも通し、EOA、1-of-1、2-of-3、insufficient threshold、wrong owner、wrong digest、wrong magic、revert、期限、owner / pause state changeを分けています。

失敗は「何となくlogin失敗」ではなく、次の理由へ分類しました。

category local再現で拒否したcase session
format / origin fields malformed ABNF、wrong version、scheme、domain、URI 発行しない
chain wrong chain ID、policyとclientのchain不一致 発行しない
nonce unknown、expired、replay、bad signature後のreuse 発行しない
time expired message、future issued-at、not-before前 発行しない
signer wrong EOA signer、wrong ERC-1271 owner 発行しない
proxy untrusted forwarded host、trusted proxyからのdisallowed origin 発行しない
session action wrong Origin、wrong CSRF token state changeを実行しない

wrong versionは現行ABNF自体に合わないため、parser段階のmalformed-messageとして落ちます。signatureを確かめる前にrejectして構いません。

session cookieにはaddressやsignatureを入れない

signatureやaddressをそのままbearer cookieにせず、認証成功ごとに256-bitのopaque session IDを新しく作ります。server側はIDのhashをkeyに、少なくとも次を保存します。

  • checksum済みaddress
  • chain ID
  • signer type(EOA / ERC-1271)
  • created / expires timestamp
  • session-bound CSRF tokenのhash

公開sampleのcookieは次の形です。

Set-Cookie: __Host-siwe=<opaque>; Path=/; Max-Age=28800; Secure; HttpOnly; SameSite=Lax

__Host- prefixを使うため、SecurePath=/を付け、Domain属性は付けません。HttpOnlyはJavaScriptからsession IDを読めなくし、SameSite=Laxはcross-site送信を減らします。ただし、SameSiteだけをCSRF対策の全部にはしません。

state-changing requestはOriginとCSRF tokenを確認する

cookieはbrowserが自動送信します。そのため、profile変更やlogoutなどstate-changing endpointでは、sessionが有効なだけでなく次も確認します。

  1. HTTP methodをPOSTなどの変更系へ限定する
  2. Originがcanonical originと一致する
  3. request bodyまたはcustom headerのCSRF tokenが、sessionに結び付くhashと一致する
  4. server-side authorizationをresourceごとに実行する

CSRF token自体をsession cookieと同じHttpOnly cookieだけに置くと、request側から別値として提示できません。login responseから安全にclient stateへ渡す、専用endpointで取得するなど、frameworkの推奨patternに合わせます。tokenをURLへ置くとreferrerやlogへ残り得るため避けます。

XSSがあると同一originからCSRF tokenを使われる可能性があります。output escaping、Content Security Policy、dependency管理などのXSS対策は別の必須layerです。

logoutとaccount変更でserver sessionを失効する

logoutはwallet接続を切るだけではありません。POST /logoutでOriginとCSRFを確認し、server recordを削除してcookieを期限切れにします。

frontendがwalletのaccountsChangedまたはchainChangedを受け取った場合も、画面表示だけを変えず、現在sessionを失効させて再認証します。server sessionがaddress Aへbindingされているのに、wallet表示だけaddress Bへ変わる状態を残しません。

SIWE challengeからaddress-bound sessionを発行し、OriginとCSRFを検証してlogout、address・chain・ERC-1271 state変更で失効する流れ

ERC-1271ではさらに、owner、threshold、moduleなどsignature validityへ影響するstate changeを検知したとき、matching sessionを失効するpolicyが必要です。webhook、event監視、重要操作前の再検証、短いsession TTLを組み合わせます。local再現ではownerを削除し、同じsignatureがtrue → falseになったことを確認してcontract accountのsessionを削除しました。

SIWE authenticationとauthorizationを分離する

ERC-4361の範囲はauthenticationです。SIWE成功から分かるのは、期待したchallengeに対してaddressのsigner proofが通り、web sessionを作れることです。

次の判断は別です。

  • 管理画面を使えるroleか
  • private documentを読めるmembershipか
  • tokenやNFTを現在保有しているか
  • transactionを送ってよいか
  • allowance、asset移動、message公開へ同意したか

これらをresourcesstatementに書いただけでserver authorizationへ変換しません。role database、resource ownership、policy engine、step-up authenticationなどをresourceごとに判定します。特にSIWE login signatureを、token approvalや資産移動への包括同意として再利用してはいけません。

利用者がwallet prompt上でSIWEをPermit、一般EIP-712、EIP-7702 delegationと分け、domain・URI・chain ID・nonceを確認する入口は、wallet署名要求の見分け方と9項目チェックリストにまとめています。

walletのdisconnectとserver logoutを同じ操作だと考えず、token allowance、Permit2、smart account module、EIP-7702 delegationまで残存権限を確認する場合は、DApp切断後の六つの権限層を参照してください。

実装前のチェックリスト

  • ERC-4361 ABNF全体を準拠parserで読み、field sizeも制限した
  • scheme、domain、URI、version、chain IDをserver-fixed policyと比較した
  • request Hostやforwarded headerをexpected valueへ無条件に採用していない
  • trusted proxy peerと最終allowed originを両方確認した
  • nonceはcryptographic random、短いTTL、hash保存、1回限りにした
  • 同時requestでもnonceの成功を1件にするtransactional consumeを実装した
  • issued-at skew、expiration-time、not-beforeをserver clockで確認した
  • EOA ERC-191と、指定chain上のdeployed ERC-1271を検証した
  • signature failure後に同じnonceを復活させていない
  • session IDを新規random値にし、address / chain / signer typeへbindingした
  • __Host-、Secure、HttpOnly、Path、SameSite、TTLをreviewした
  • state-changing requestでOriginとsession-bound CSRF tokenを確認した
  • logout、session expiry、address / chain changeでserver sessionを失効した
  • ERC-1271 state change時の再検証・失効方法を決めた
  • authenticationとresource authorization、transaction / asset consentを分離した
  • rate limit、TLS、logのsecret除外、XSS対策、shared storeの障害挙動をreviewした
  • local再現結果を全wallet・provider・chain対応やsecurity auditの証拠にしていない

SIWE実装の完了条件は「signature libraryがtrueを返した」ではありません。期待originとrequestを固定し、single-use challengeを先に消費し、signerの現在状態を確認して、失効できるaddress-bound sessionへ移すことです。

この記事にスポンサー、affiliate、wallet接続、署名要求、transaction、資産移動のCTAはありません。将来、wallet、auth provider、RPC、session service、audit serviceの広告を置く場合も広告であることを明示し、field policy、nonce state、signature verification、session / CSRF、failure matrix、security reviewから独立させます。

確認した一次情報