2026-08-24 SIWE nonce の単回性はサーバー側で保証しない(Issue #50)
- 課題: Issue #50 は未認証の入口(
GET /api/auth/nonce/POST /api/auth/verify)のレート制限を扱っていて、本番構成の判断待ちになっている。その中で「同じ封緘Cookieを送り続ければ検証を何度でも走らせられる」と書いた点について、レート制限とは別の問題(nonce の単回性がサーバー側で成立していない)ではないか、本番構成と独立に「どの層で保証するか」を先に決めてはどうか、という提案があった。この記録はその切り出しに答えるもので、レート制限そのものは引き続き #50 で扱う。
コードを読んで分かった前提
app/api/auth/nonce/route.ts / app/api/auth/verify/route.ts / lib/siwe.ts / lib/session.ts の現状。
- nonce は iron-session の封緘Cookieの中にしかなく、サーバー側ストアはない。 検証時に
session.nonce = undefinedとして保存し直すが、これが効くのは新しいSet-Cookieを受け入れるクライアントだけ。古い封緘Cookieを持ち続ける相手には効かない - 成功する組(封緘Cookie + message + signature)のリプレイが通る窓は、message の
expirationTimeまで。 クライアントは10分の期限を入れ、サーバーは残り時間の上限を15分にしている(MAX_SIWE_LIFETIME_MS)。過ぎればexpiredで落ちる - リプレイで得られるのは、同じアドレスの、同じ権限のセッション。
members.upsertByAddress()は冪等で、行も権限も増えない。リプレイを行うには nonce 発行時の封緘Cookie(HttpOnly)と署名の両方が要るので、主体は「その Cookie を持っている相手」に限られる - 失敗する組のリプレイは、封緘Cookie自体の期限(14日)まで続けられる。 ただし増えるのは CPU と関数実行回数だけで、これは #50 本体(エッジ側のレート制限)が扱う問題と同じ
- 検証成功の監査記録は今のところ存在しない。 サインイン履歴を持っていないので、「1検証成功 = 1つの意図した操作」という性質が効く相手がまだない
決定
1. nonce の単回性はサーバー側では保証しない
代わりに次の2つでリプレイの主体と窓を限定し、これを現時点の保証とする。
| 制約 | 何を限定するか | 根拠 |
|---|---|---|
| 封緘Cookieへの束縛 | 誰がリプレイできるか(その Cookie の保持者だけ) | session.nonce と一致しなければ検証に入らない |
| message の有効期限(≤15分) | いつまで成功する組が通るか | lib/siwe.ts の expired / expiration too long |
MVP-1 では対象外と明記する。理由は、サーバー側の状態を置いても、いま守れるものが増えないため。
- 攻撃者は
GET /nonceで新しい nonce を無料で取り直せるので、使い捨てを強制しても総リクエスト回数はほぼ減らない - nonce 消費の記録を DB に置くと、
2026-08-10-rate-limit-authenticated-actions.mdで未認証の入口に対して退けたのと同じ逆転(検査のほうが守る対象より高くつき、外部から無料で DB 書き込みを強制できる)が起きる - 成功する組のリプレイで得られるものが、同じ人の同じセッションでしかない
2. 再評価の条件
次のいずれかが起きたら、この決定に戻ってくる。
- レート制限の単位を IP 以外(nonce 発行回数など)へ広げる必要が出たとき。 サーバー側に nonce の状態がないと、この選択肢は最初から取れない
- サインイン履歴を監査記録として持つと決めたとき。 そのとき初めて「1検証成功 = 1操作」の性質が意味を持つ
- セッションストアを Cookie から DB へ移すとき(
2026-08-10-session-ttl.mdで見送り中)。nonce も同じ場所に置くのが自然になる
3. そのときの実装候補
rate_limits と同じ Supabase PostgreSQL に siwe_nonces を置き、発行時に1行 insert、検証時に delete ... where nonce = $1 returning * で消費する。読み取りと削除を分けると同時呼び出しで2回通るので、1文で行う。
エッジ側のレート制限(#50)が入っていることを前提条件にする。 前提なしにこれを入れると、決定1で挙げた逆転が起きる。
検討して採らなかったもの
- nonce の発行時刻を封緘Cookieへ入れ、古い nonce を拒否する(DB不要)。 message の有効期限が既に同じ上限を課しているため、縮まるのは「失敗する組のリプレイの窓」だけで、CPU と関数実行回数の問題は変わらない。価値が薄いので見送る
- nonce を Cookie ではなくレスポンスボディだけで返し、サーバー側ストアと突き合わせる。 サーバー側ストアが要る点は上と同じで、Cookie 束縛(主体の限定)を失う分だけ悪くなる
影響
docs/guide/architecture.mdの SIWE の節で「nonce を使い捨てにしているのは同じ署名の再利用を防ぐため」と書いていた箇所を、実際の保証(Cookie 束縛 + 有効期限)に合わせて書き直したapp/api/auth/nonce/route.tsの冒頭コメントも同じ内容に合わせた。挙動は変えていない- Issue #50 の「決める必要がある点」から、この件は外れる。#50 は引き続きホスティング側のレート制限(単位・上限・超過時のふるまい)を扱う