2026-09-20 未認証エンドポイントのレート制限をVercel WAFで行う(Issue #50)【決定案】
状態: 決定案。Issue #50 での合意後に「決定案」の表記を外す。ルールの作成は公開切替の作業に含める。
- 課題: 未認証で叩ける入口(
GET /api/auth/nonce/POST /api/auth/verify、および後から加わったGET /api/community-pulse)への連打が、本番公開前に抑えられていない。Issue #50 は「ホスティング側の層で何が使えるか」の確認待ちで止まっており、含まれない場合は Upstash / Vercel KV の導入判断へ戻る前提だった。 - 認証後のServer Actionは #37 の対応で済んでいる。ここで扱うのは未認証の入口だけ。
前提として確認した事実(2026-09-20)
Vercel WAFのレート制限
WAF Rate Limiting(最終更新 2026-08-28)の記載から。
- すべてのプランで使える。 ルール数は Hobby が1プロジェクトあたり1本、Pro が40本、Enterprise が1000本。Hobby はカスタムファイアウォールルール全体でも3本まで
- 数える鍵は Hobby / Pro では IP と JA4 Digest のみ。User-Agent や任意ヘッダーは Enterprise のみ
- アルゴリズムは Hobby / Pro は固定窓のみ。窓は最小10秒・最大10分(Enterprise は最大1時間)
- 超過時の動作は Default (429) / Log / Deny / Challenge から選ぶ。ドキュメントは「まず Log で影響を観測してから制限や遮断を適用する」使い方を案内している
- Deny は 429 ではなく
403 Forbiddenを返す(Firewall concepts)。レート制限として 429 を返すのは Default (429) の方 - 設定はダッシュボードの Firewall から行い、Review Changes → Publish で本番へ反映する
- 含まれる量は Hobby が月100万の許可リクエスト、Pro は従量。課金はリクエスト元のリージョンによる
- カウンターはリージョンごとに持たれる。同じ鍵の通信が複数リージョンに分かれると、設定した上限を全体では超えうる
つまり、Issue #50 が確認待ちとしていた「契約プランに含まれるか」は解消した。 Upstash / Vercel KV を入れる判断に戻る必要はない。
@vercel/firewall(Rate Limiting SDK)は代わりにならない
Rate Limiting SDK の checkRateLimit() は関数の中で呼ぶものなので、関数の起動そのものは防げない。Issue #50 が「課金を止められるのはエッジ側だけ」と書いた理由がそのまま当てはまる。IP以外の鍵(認証済みユーザーIDなど)が要るときの手段であって、今回の目的には使わない。
アプリ側の現状
GET /api/auth/nonceは封緘Cookieへnonceを書いて返すだけで、DBを触らないPOST /api/auth/verifyは検証に成功したときだけmembers.upsertByAddress()を呼ぶ。失敗する署名検証はDBに届かないGET /api/community-pulseはワーカー単位で取得をまとめ、失敗後は最低60秒の再試行抑止を持つ(Community Pulseの決定記録)。ただしIP単位の上限は持たない- どれも未認証で叩けるため、増えるのは関数の実行回数とCPU
決定案
1. 層と単位
Vercel WAFのレート制限ルールを置き、鍵はIPにする。 JA4 Digest はTLSの指紋で、IPを変える相手には強いが、同じブラウザ・同じ設定の利用者をまとめてしまう。未認証の入口で手がかりがIPしかないという Issue #50 の整理をそのまま採る。
ルールは用途ごとに2本に分け、カウンターを共有しない。 条件はパスで絞る。画面の読み込みは /api/ に入らないので数に入らない。
| ルール | 条件(パス) | 守るもの |
|---|---|---|
| 認証 | /api/auth/ で始まる | nonce の発行と署名検証。連打されると関数の実行回数が増える |
| Community Pulse | /api/community-pulse | GitHub からの取得をまとめる経路。連打されると関数の実行回数が増える |
分ける理由は、同じIPからの Pulse の読み込みが認証の枠を消費しないようにするため。Home を開くたびに Pulse を1回取得するので、1本にまとめると Home の再読込がサインインの余地を削る。用途ごとに観測結果を見て上限を別々に調整できる点も、1本では得られない。
この構成は Pro プラン(ルール40本)を前提にする。このプロジェクトで Pro を使えることは Masumi から確認を得ている(PR #122 のレビュー、2026-09-22)。ただし実際の契約と設定画面はまだ確認しておらず、ルール作成の時点で確かめる。Hobby だった場合はルールが1本しか作れず、条件はすべてANDで評価されるため2本には分けられない。そのときは条件を /api/ で始まるパスの1本にまとめ、上限は認証の値をそのまま使う。
2. 上限と窓
どちらのルールも既定値のまま、60秒あたり100リクエストから始める。 根拠は次のとおり。
- 1回のサインインは nonce と verify の2リクエスト。署名のやり直しを含めても、1人が1分間に使うのは多くて10程度
- 同一ネットワーク(イベント会場のWi-Fiなど)から同時にサインインする場合でも、100/分は同時に十数人が繰り返し試せる水準
- Pulse は Home の読み込み1回につき1リクエスト。同じネットワークから100人が同時に開いても1分に収まる
- 一方で、連打する側はどちらも1分100回・1時間6000回に抑えられる
トラフィックの実績がまだないため、最初はどちらも動作を Log にして観測し、実際の分布を見てから Default (429) へ切り替える。 切替先は Deny ではない。Deny は 403 を返すため、次の節で用意した 429 の分岐に入らず、画面は従来の「もう一度お試しください」に戻ってしまう。観測期間と切り替えの判断、上限値の見直しはルールごとに Issue #50 で記録する。
3. 超過時のふるまい
- エッジで落とされるため、429は関数に届かない。アプリのログには残らず、Firewallの画面で見る
- 画面には「待てば戻る」ことを伝える。
lib/auth/signInWithWallet.tsは429を他の失敗と分けて扱い、「アクセスが集中しています。しばらく待ってから、もう一度お試しください。」を出す。従来の「もう一度お試しください」「もう一度署名してください」は、上限に当たっている相手に再試行を促し、上限を消費し続けさせてしまう - Community Pulse は、エッジで 429 になると前回成功分を出さない。 前回成功分を保持するのはサーバー側の取得・キャッシュ経路で、WAF が 429 を返すとそこに到達しない。
assets/reference/gateway/community-pulse.jsは応答がokでない時点で失敗として扱い、カード一覧を空にして「Issueを取得できませんでした」と GitHub の一覧への案内を出す。Home の読み込み1回につき1リクエストなので、上限に当たるのは連打している相手であり、この表示のままにする。エッジ 429 でも前回成功分を見せたくなったら、クライアント側で保持する変更とその検証を別に扱う - Default (429) へ切り替えるときは、実際の応答と画面を確認する。 上限を超える回数を送って応答が
403ではなく429であること、サインインの画面に「アクセスが集中しています」の待機メッセージが出ることを確かめてから、切替を完了にする
4. 対象外
- nonceの単回性はここでは変えない。 2026-08-24の決定のとおり、封緘Cookieへの束縛とmessageの有効期限(≤15分)でリプレイの主体と窓を限定する
- アプリ内での未認証リクエストの計数。DBで数えると検査のほうが守る対象より高くつき、外部から無料でDB書き込みを強制できる形になる(Issue #50 の整理のまま)
- Challenge(ボット判定)の利用。未認証の入口に足すと、ウォレット拡張からの通信が通らない場合の切り分けが難しくなる
残ること
- ルールの作成は本番の公開切替作業の一部として行う。ポータルアプリのガイドの公開切替手順に項目を足した。作成時に契約プランを設定画面で確かめ、Hobby なら1本に縮退する
- Log での観測期間、Default (429) へ切り替える判断、実際の上限値の見直しはルールごとに Issue #50 で記録する
- リージョンごとのカウンターのため、全体で厳密な上限にはならない。厳密さが必要になったら、そのときに別の層を検討する
参考: WAF Rate Limiting、Rate Limiting SDK、認証後のレート制限の決定、SIWE nonceの単回性。