Skip to content

2026-08-10 レート制限を認証後のServer Actionから始める(Issue #37)

  • 課題: リポジトリ全体にレート制限の実装がなく、未認証で叩ける入口(GET /api/auth/nonce / POST /api/auth/verify)と、認証後に無制限に呼べるServer Action(submitApplication / checkin / transitionApplication)があった。データの正しさが壊れる問題ではなく、公開したときにコストと運営の可視性が削られる問題。

コードを読んで分かった前提

Issueの記述より状況が具体的だった。判断はこの2点に依っている。

  • 失敗する署名検証はDBを触らない。 app/api/auth/verify/route.tsmembers.upsertByAddress() は検証成功後にだけ呼ばれる。連打で増えるのはCPUと関数実行回数だけ。
  • nonceの使い捨ては攻撃者には効かない。 サーバーはnonceをiron-sessionの封緘Cookieにしか持たず、サーバー側ストアがない。攻撃者はnonceを1回取得したあと、同じ封緘Cookieを送り続ければ検証を何度でも走らせられる。 session.nonce = undefined が効くのは Set-Cookie を受け入れる行儀のよいクライアントだけ。

決定

1. 未認証の入口と認証後の入口を分け、まず認証後だけを扱う

アプリ内のレート制限は「関数実行の課金」を減らさない。 429を返す判断をするには関数が起動している必要がある。課金を止められるのはエッジ側だけなので、未認証の入口はホスティング側(Vercelのファイアウォール等)の層で扱う。ここは本番構成の判断(docs/decisions/2026-08-06-phase-1-local-validation.md でデプロイ延期中)とセットになるため、このPRには含めない。

この分割は「未認証側を後回しにしてよい」という意味ではない。 本番公開前に決める必要があり、別Issueとして残す。

2. 認証後の入口はSupabase PostgreSQLで数える。Redis等は入れない

選択肢採否
Supabase PostgreSQL採用。 これらのActionはもともとDBを触るので往復が追加コストにならない
プロセス内メモリ不採用。Vercelはインスタンスをまたげず、保証にならない
Upstash / Vercel KV不採用。ベンダーとシークレットが増え、「Redisが落ちたとき通すか止めるか」の判断も抱える。現状はデータの正しさではなくコストの問題で、その対価に見合わない

未認証の入口をDBで数えるのは逆効果になる。 secp256k1の復元はCPUで1ms未満、Supabaseへの往復は数ms。検査のほうが守る対象より高くつき、外部から無料でDB書き込みを強制できる形になる。認証後の入口に限れば、この逆転は起きない。

3. 数える単位はウォレットアドレス

IPではなくアドレスにした。IP単位だと同一ネットワークのメンバーがまとめて詰まる(Issueが挙げていた懸念)。認証後なので安定した識別子が手に入り、これはIPにはない利点になる。

管理者の状態更新は管理者のアドレスで数える。

4. 数え上げと判定はDB側の1文で行う

consume_rate_limit()insert ... on conflict do update で「加算して返す」までを1文にまとめ、その結果を上限と比べる。読み取り→加算→書き込みを別々のSQLで行うと、同時に呼ばれたときに上限を越えて通ってしまう。 on conflict do update は対象行のロックを取るので、この1文の中で直列化される。

固定ウィンドウを採った。スライディングウィンドウは履歴を行として持つ必要があり、ノイズを減らすための仕組みが自分でノイズを増やす。 ウィンドウ境界での揺れ(最悪で上限の2倍が短時間に通る)は、今回の目的に対して許容できる。

5. 上限値

入口上限単位
submitApplication5回 / 24時間メンバー
checkin20回 / 24時間メンバー
transitionApplication120回 / 1時間管理者

「自動化された連打だけを止め、人間の正当な操作は届かない」水準で決めた。 状態更新が1時間120回なのは、運営が申請30件をまとめて捌く場合(1件あたり審査・Allowlist・配布で3操作 = 90回)を想定したもの。ここを絞りすぎると、ノイズを減らす代わりに正当なまとめ作業を弾く。

6. 認可の直後、処理の前に数える

止めたいのは成功する操作ではなく、DBの制約に弾かれ続ける呼び出しのほう(完走していない申請、同日2回目のチェックイン、遷移ルールに反する更新)。処理の後ろに置くと数え漏らす。transitionApplication では全申請を読む listAll() より前に置いた。

DBへ到達できないときは例外がそのまま伝わり、通してしまわない。これらのActionはいずれにせよ同じDBを必要とするので、ここだけ通しても先へ進めない。

副次的な変更

vitest.config.tsfileParallelism: false を追加した。 統合テストは1つのローカルSupabaseを共有するため、ファイルを並列に走らせると片方の truncateAll がもう片方の実行中の行を消す。2ファイル目を足した時点で外部キー違反として表面化した。単体テストは十分速いので、分けずに全体を直列にしている。

含めなかったもの

  • 未認証の入口(/api/auth/nonce / /api/auth/verify)のレート制限。 上記1のとおり、本番構成の判断とセットで別途扱う
  • CAPTCHA等
  • WAF・DDoS対策そのもの
  • 超過時に「あと何秒待てばよいか」を返すこと。固定ウィンドウの残り時間を返す形にはできるが、まず上限が実際に必要な水準かを見てから決めたい

残る課題

rate_limits の行は (bucket × 利用者数) で頭打ちになるため放置しても増え続けないが、使われなくなった行を消す仕組みはない。 規模が問題になった時点で、ウィンドウを過ぎた行の定期削除を足す。

秘密情報(SESSION_PASSWORD、Supabaseキー、Safeの認証情報)はドキュメント・Issue・ログへ貼らないでください。