メインコンテンツへスキップ
友田 陽大
アプリ層セキュリティ
Next.js
アーキテクチャ設計
セキュリティ
TypeScript

Next.jsで『正しく効く』レート制限 — サーバーレスでインメモリが壊れる理由と、分散ストア設計

Vercel/Lambdaはインスタンスが使い捨て&並行で動くため、プロセス内メモリのレート制限は素通りします。Upstash Redis等の分散ストアでアトミックなスライディングウィンドウを実装する設計を、Next.jsの実コード(middleware/route handler)で解説します。

公開日
読了時間
26分
著者
友田 陽大
シェア

最初に結論を述べます。サーバーレスのNext.js(Vercel / AWS Lambda)でレート制限を「プロセス内の Map でカウントする」実装は、本番では確実に壊れます。 関数インスタンスが使い捨てで、複数同時に立ち上がり、コールドスタートのたびに別物になるため、カウンタが共有されないからです。ローカルでは完璧に動き、テストも通り、デモでも問題なく見える——だからこそ厄介で、本番のスケール下で静かに素通りします。

これは「Next.jsが悪い」「サーバーレスを使うな」という話ではありません。サーバーレスは正しい選択で、レート制限も実装できます。問題は、レート制限が「状態(カウンタ)を持つ処理」なのに、サーバーレスが「状態を持たない(ステートレス)」前提で動くという、設計の根本的なミスマッチにあります。本記事は、なぜインメモリが壊れるのかをアーキテクチャから説明し、固定ウィンドウとスライディングウィンドウの違い、アトミックなインクリメントの必要性、キー設計、クライアントIPの正しい取得、そして 429 + Retry-After までを、実コードと公開された一次情報に基づいて設計します。AI生成コードがほぼ必ず間違える箇所でもあるので、自分で書く人にもレビューする人にも効くはずです。


1. なぜレート制限が要るのか——抑えるのは4つの濫用

レート制限は「1つのクライアントが、一定時間に叩ける回数の上限」を決める仕組みです。何を守るのか、具体的な脅威で押さえます。

脅威何が起きるかレート制限の効き方
ログインのブルートフォースパスワード総当たり・クレデンシャルスタッフィングIP/アカウント単位で試行回数を絞り、総当たりを非現実的にする
OTP / メール濫用認証コード送信やパスワードリセットの連打送信回数を制限し、SMS/メール費用と着信スパムを抑える
スクレイピング / API濫用公開エンドポイントの大量取得・在庫監視ボットデータ流出と負荷を抑え、正規利用者の体験を守る
コスト型DoS重い処理(AI推論・画像生成・外部API)を連打して課金を爆発させる単価の高い処理ほど厳しく制限し、請求書の暴走を止める

最後の「コスト型DoS」は、サーバーレス時代に特に重要です。従量課金のクラウドでは、攻撃者はサーバーを落とす必要すらありません。重いエンドポイントを叩き続けるだけで、あなたの請求書が青天井になります。これはOWASPが API4:2023 Unrestricted Resource Consumption(無制限のリソース消費) として、API Security Top 10の上位に位置づけているリスクそのものです。リクエストを処理するには帯域・CPU・メモリ・ストレージに加え、SMSやサードパーティAPIのような金銭的コストがかかる——その消費に上限を設けないと、可用性とコストの両方が攻撃面になる、というのがOWASPの指摘です。

レート制限は、これら4つの濫用に対する最も費用対効果の高い水平統制です。アプリ横断で一律に効き、正しく実装すれば「人間が毎回考える」必要もありません。Next.jsのアプリ層セキュリティ全体の中での位置づけはNext.js × Supabase アプリケーションセキュリティ完全ガイドで地図にしているので、全体像はそちらを参照してください。本記事はその中の「レート制限」を、サーバーレス特有の落とし穴に絞って深掘りします。


2. なぜサーバーレスでインメモリ Map が壊れるのか

検索すれば、こういうコードが大量に出てきます。AIに「Next.jsでレート制限を実装して」と頼むと、かなりの確率でこれが返ってきます。

// ❌ 壊れる:プロセス内 Map にカウンタを置くレート制限
const hits = new Map<string, { count: number; resetAt: number }>();

export function rateLimit(ip: string, limit = 10, windowMs = 60_000): boolean {
  const now = Date.now();
  const rec = hits.get(ip);

  if (!rec || now > rec.resetAt) {
    hits.set(ip, { count: 1, resetAt: now + windowMs });
    return true; // 許可
  }
  if (rec.count >= limit) return false; // 拒否
  rec.count += 1;
  return true;
}

next dev の単一プロセスでは完璧に動きます。だから「動いた」と思って本番に出る。しかしサーバーレスでは、この hits マップが当てにならない理由が3つあります。

2-1. インスタンスは使い捨て(ステートレス)

VercelやLambdaの関数は、リクエストを処理するために起動し、しばらくして破棄されます。プロセスメモリは関数インスタンスの寿命に縛られ、インスタンスが消えれば hits マップも丸ごと消えます。次のリクエストが新しいインスタンスで処理されれば、カウンタは 0 からやり直し。攻撃者は「インスタンスが入れ替わるのを待つ」だけで上限をリセットできます。Next.jsの公式ドキュメントも、サーバーレス/エッジ関数がステートレスである前提で設計するよう繰り返し述べています(Next.js docs)。

2-2. 複数インスタンスが同時に動く(水平スケール)

これが最も致命的です。負荷が上がると、プラットフォームは関数を水平にスケールさせ、同時に何十・何百ものインスタンスを立ち上げます。各インスタンスは自分専用の hits マップを持ち、互いのカウントを知りません。

実際の挙動:limit=10/分 のつもりが、インスタンスごとに 10 を許す

  攻撃者 ──┬─→ インスタンスA(自分の Map: 10回まで許可)
          ├─→ インスタンスB(別の Map: さらに10回)
          ├─→ インスタンスC(別の Map: さらに10回)
          └─→ …N個 → 実効上限 = 10 × N(事実上、無制限)

ロードバランサがリクエストを各インスタンスに分散するほど、攻撃者が通せる合計回数はインスタンス数に比例して増えます。つまりスケールすればするほど、レート制限は緩くなる——意図と真逆です。

2-3. コールドスタートでリセットされる

しばらくアクセスが無いと関数は休眠し、次のリクエストでコールドスタートします。このときプロセスは作り直され、メモリ上のカウンタは初期化されます。トラフィックが断続的なほど、カウンタは頻繁にリセットされ、「窓」の意味が失われます。

結論:レート制限は本質的に「複数インスタンスで共有される状態」を必要とする。 その状態をプロセスメモリに置く限り、サーバーレスでは原理的に正しく数えられません。状態はプロセスの外——全インスタンスから見える単一の共有ストアに置くのが唯一の正解です。

「ローカルで動いた」は反証にならない。 インメモリ実装の恐ろしさは、開発・テスト・小規模デモのすべてで正常に見えることです。壊れるのは「本番でスケールしたとき」だけ。だから事故は、最も困るタイミング(攻撃を受けている最中)に顕在化します。検証は単一プロセスではなく、複数インスタンスを想定して設計する必要があります。


3. 固定ウィンドウ vs スライディングウィンドウ——境界バースト問題

状態を共有ストアに移すと決めたら、次は「どう数えるか」です。素朴な実装は**固定ウィンドウ(fixed window)**ですが、これには見落としやすい欠陥があります。

3-1. 固定ウィンドウの境界バースト

固定ウィンドウは「毎分00秒にカウンタをリセットする」方式です。実装は単純ですが、ウィンドウの変わり目で上限の2倍を通してしまう問題があります。

limit = 10/分、固定ウィンドウの場合:

  12:00:00 ───────────── 12:00:59 │ 12:01:00 ───────────── 12:01:59
                          ↑10回    │ ↑10回
                  12:00:59 に10回   │  12:01:00 に10回
                  ───────────────────────────
                  60秒未満の間に 20回 通過してしまう

12:00:59 に10回、12:01:00 に10回を送ると、実質1秒ちょっとの間に20回が通ります。各ウィンドウ単体では上限を守っているのに、ウィンドウをまたぐと上限が崩れる——これが**境界バースト問題(boundary burst)**です。ブルートフォースやコスト型DoSのように「短時間に集中させたい」攻撃者は、この境界を狙います。

3-2. スライディングウィンドウで滑らかに数える

**スライディングウィンドウ(sliding window)**は「今この瞬間から過去60秒」を常に見ます。固定の区切りが無いので、境界バーストが起きません。

スライディングウィンドウ:リクエスト時刻を起点に「直前の60秒」を毎回数える

  ……[━━━━━━ 直前の60秒間のリクエスト数を数える ━━━━━━]→ 今
       この窓は時間とともに連続的にスライドする(固定の区切りが無い)

最も厳密な実装は、各リクエストのタイムスタンプをソート済み集合(Redisの ZSET)に記録し、毎回「過去60秒の範囲に何件あるか」を数える方式です。正確ですが、リクエストごとに件数分のメモリを使います。@upstash/ratelimit が提供する slidingWindow は、これを現ウィンドウと前ウィンドウの加重平均で近似し、メモリ効率と滑らかさを両立しています。多くのアプリではこの近似で十分です。

アルゴリズム長所短所向く用途
固定ウィンドウ実装が単純・軽い境界バーストで上限の2倍を通す厳密さより軽さ優先の緩い制限
スライディングウィンドウ(ログ)厳密に正確リクエスト数分のメモリを消費課金・OTPなど厳密さが要る箇所
スライディングウィンドウ(近似)滑らか&メモリ効率が良いわずかな誤差を許容大半のAPI/ログイン制限の既定

迷ったらスライディングウィンドウの近似を既定にしてください。固定ウィンドウは「境界で2倍通る」ことを許容できる場面に限ります。


4. アトミックなインクリメント——並行リクエストで数を壊さない

共有ストアに移し、スライディングウィンドウを選んでも、まだ穴があります。**並行性(concurrency)**です。

レート制限の中核は「現在値を読む → 加算する → 書き戻す」という read-modify-write です。これが分割可能(非アトミック)だと、同時に来た複数リクエストが同じ古い値を読んで、揃って「まだ上限以下だ」と判断し、全部通してしまいます。これを**競合状態(race condition)**と呼びます。

limit=10、現在値=9 のときに 5 本が同時到着(非アトミックな実装)

  req1: GET→9  req2: GET→9  req3: GET→9  req4: GET→9  req5: GET→9
   全員「9 < 10 だから OK」と判断
  → SET 10 が5回走り、本来1本しか通らないはずが 5本 通過(=14回目まで通る)

サーバーレスでは複数インスタンスが同時に同じキーを叩くため、この競合は例外ではなく常態です。防ぐには、read-modify-write を1つの分割されない操作として実行する必要があります。手段は2つあります。

4-1. Redisのアトミックなコマンド/Lua

Redisはコマンドを直列に実行するため、INCR のような単一コマンドは原子的です。複数手順を1単位にしたいときは、Luaスクリプトで「読み・加算・TTL設定・判定」をサーバー側でまとめて実行します。スクリプト全体が分割されないので、競合が起きません。

// アトミックなレート制限を Lua で表現(読み・加算・初回のみTTL設定・判定を1単位で実行)
// KEYS[1] = レート制限キー / ARGV[1] = 上限 / ARGV[2] = ウィンドウ秒
const SLIDING_LUA = `
  local current = redis.call('INCR', KEYS[1])
  if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[2])  -- 初回だけ有効期限を設定
  end
  if current > tonumber(ARGV[1]) then
    return {0, current}                     -- 拒否
  end
  return {1, current}                        -- 許可
`;
// ↑これは固定ウィンドウの原子化。スライディングは ZSET で範囲削除→ZCARD→ZADD を
//   同じ Lua にまとめる(手書きするなら下記の通り、まず @upstash/ratelimit を勧める)

4-2. 分散レート制限器(推奨)

Luaを手書きすると、TTLの境界・時計のズレ・スライディングの近似など、地雷が多い。車輪の再発明は避けて、検証済みのライブラリを使うのが正解です。@upstash/ratelimit は内部でアトミックなスクリプトを使い、サーバーレス(Vercel Edge / Lambda)から呼べるHTTPベースのRedis(Upstash)と組み合わせて動きます。アトミック性・スライディングウィンドウ・分散カウントを自前で保証しなくて済むのが最大の利点です。

// lib/ratelimit.ts — 分散ストアにアトミックなスライディングウィンドウを置く
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import "server-only"; // この計数ロジックはサーバー専用。クライアントに混入させない

// 全関数インスタンスから見える単一の共有ストア(Redis)。プロセスメモリには置かない
export const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(), // UPSTASH_REDIS_REST_URL / _TOKEN を env から読む
  limiter: Ratelimit.slidingWindow(10, "60 s"), // 直前60秒で10回まで(近似)
  analytics: true,
  prefix: "rl", // キーの名前空間
});

Redis.fromEnv() が読む値は秘密情報です。UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKENenv にだけ置き、絶対に NEXT_PUBLIC_ を付けないでください。レート制限ロジックをクライアントから呼べるようにしてしまうと、上限そのものを攻撃者が観測・回避できます。


5. キー設計——IP・ルート・ユーザーの粒度を決める

「誰の何回を数えるか」を決めるのがキー設計です。粒度を間違えると、緩すぎて効かない/厳しすぎて巻き添えが出る、のどちらかになります。

キーの軸向く用途注意点
IP単位rl:ip:203.0.113.5未ログインの経路(ログイン試行・公開API)NAT/プロキシ配下で正規利用者を巻き添えにしうる
ルート単位rl:login:… / rl:ai:…エンドポイントごとに上限を変える重い処理ほど厳しく、軽い処理は緩く
ユーザー単位rl:user:<uid>ログイン後の濫用・コスト型DoS認証済みでのみ可能(IDは検証済みの値を使う)

実務では複数の軸を組み合わせるのが定石です。たとえば「ログイン試行はIP単位かつアカウント単位の両方で制限」「AI推論など単価の高い処理はユーザー単位で厳しく」。ルートごとに別々の Ratelimit インスタンスを用意し、上限をエンドポイントの「重さ」に合わせます。

// ルートの「重さ」に応じて別々の上限を持つ(重い処理ほど厳しく)
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();

export const loginLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, "60 s"), // ログインは厳しめ:5回/分
  prefix: "rl:login",
});

export const aiLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(20, "1 h"), // 単価の高いAI推論:20回/時
  prefix: "rl:ai",
});

巻き添え(false positive)に注意。 IP単位だけで厳しく絞ると、企業や学校・モバイルキャリアのNAT配下にいる多数の正規利用者が1つのIPを共有しているケースで、無関係な人が締め出されます。認証後はユーザー単位を主軸にし、IP単位は未ログイン経路の防衛線として併用する——この使い分けが、効果と巻き添えのバランスを取ります。


6. クライアントIPの正しい取得——x-forwarded-for の罠

IP単位で数えるなら、「クライアントの本当のIP」を正しく取る必要があります。ここがセキュリティ上、最も誤りやすい点です。

サーバーレスでは、リクエストはプラットフォームのプロキシ/ロードバランサを経由して関数に届きます。このとき元のクライアントIPは x-forwarded-for ヘッダーに積まれます。問題は——ヘッダーはクライアントが偽装できることです。攻撃者が x-forwarded-for: 1.2.3.4勝手に付けて送れば、レート制限のキーを毎回変えて上限を回避できます。

// ❌ 危険:x-forwarded-for を無条件に信じる(攻撃者が偽装してキーを変え放題)
const ip = req.headers.get("x-forwarded-for") ?? "anonymous";

// ❌ 危険:複数値の末尾を取る(末尾はクライアントが注入できる)
const ip = req.headers.get("x-forwarded-for")?.split(",").pop()?.trim();

x-forwarded-forクライアント, プロキシ1, プロキシ2 のようにカンマ区切りで連なります。各プロキシが「自分が受け取った相手のIP」を追記していく仕組みです。だから「信頼できるのは、自分が信頼するプロキシが付けた値だけ」になります。

正しい取り方は、プラットフォームに依存します。

  • Vercel は、改ざんできない専用ヘッダー x-real-ip(および x-vercel-forwarded-for)を付けてくれます。Vercel上では、生の x-forwarded-for の末尾を拾うより、これらのプラットフォームが保証するヘッダーを使うのが安全です。
  • 自前のプロキシ(Nginx/ALB等)配下では、「信頼できるプロキシの数(trusted hops)」を知ったうえで、x-forwarded-for右から数えて N番目=最後の信頼プロキシが受け取ったIPを採用します。クライアントが先頭に注入した偽の値を読まないためです。
// lib/client-ip.ts — 信頼できるプロキシ経由でのみIPを採る
import "server-only";

/**
 * クライアントIPを安全に解決する。
 * Vercel ではプラットフォームが付ける x-real-ip を信頼する(改ざん不可)。
 * 自前プロキシ環境では trustedHops を環境に合わせて設定し、右からN番目を採る。
 */
export function getClientIp(req: Request, trustedHops = 1): string {
  // 1) Vercel が保証するヘッダーを最優先(クライアントは上書きできない)
  const real = req.headers.get("x-real-ip");
  if (real) return real;

  // 2) x-forwarded-for は「右から数えて trustedHops 番目」を採る
  //    (末尾=最後の信頼プロキシが観測したIP。先頭はクライアントが偽装可能)
  const xff = req.headers.get("x-forwarded-for");
  if (xff) {
    const parts = xff.split(",").map((s) => s.trim()).filter(Boolean);
    const idx = parts.length - trustedHops;
    if (idx >= 0 && parts[idx]) return parts[idx];
  }

  // 3) どれも無ければ匿名扱い(共有キーになるため最も厳しい上限を適用すべき)
  return "anonymous";
}

原則を一言で:「信頼できるプロキシが付けたヘッダー以外は、IPの根拠にしない」。 どのヘッダーを信じてよいかは、自分のデプロイ構成(Vercelなのか、前段にCloudflareやALBがいるのか)で決まります。ここを曖昧にしたまま x-forwarded-for の末尾を読むのが、最頻出の回避口です。


7. 実装——middleware と route handler

部品が揃ったので、Next.jsに組み込みます。配置場所は2つあり、目的で使い分けます。

7-1. middleware で「広く・早く」弾く

middleware.ts は全リクエストの前段で走るので、横断的な制限(同一IPからの全体的な濫用、特定パス配下の保護)に向きます。重い処理に到達するに弾けるのが利点です。

// middleware.ts — エッジ手前で横断的にレート制限し、429 + Retry-After を返す
import { NextResponse, type NextRequest } from "next/server";
import { ratelimit } from "@/lib/ratelimit";
import { getClientIp } from "@/lib/client-ip";

export async function middleware(request: NextRequest) {
  const ip = getClientIp(request);

  // ルートを名前空間に含め、エンドポイントごとに数を分ける
  const { success, limit, remaining, reset } = await ratelimit.limit(
    `${ip}:${request.nextUrl.pathname}`,
  );

  // 上限・残数・リセット時刻を常に返す(クライアントが自制できる)
  const headers = new Headers();
  headers.set("RateLimit-Limit", String(limit));
  headers.set("RateLimit-Remaining", String(Math.max(0, remaining)));
  headers.set("RateLimit-Reset", String(Math.ceil((reset - Date.now()) / 1000)));

  if (!success) {
    headers.set("Retry-After", String(Math.ceil((reset - Date.now()) / 1000)));
    return NextResponse.json(
      { error: "Too Many Requests" },
      { status: 429, headers }, // ← 429。Retry-After で「何秒後に再試行可」を伝える
    );
  }

  const res = NextResponse.next();
  headers.forEach((v, k) => res.headers.set(k, v));
  return res;
}

// 保護対象を絞る(静的アセット等は除外し、無駄なRedis呼び出しを避ける)
export const config = {
  matcher: ["/api/:path*", "/login"],
};

7-2. route handler で「ピンポイントに」絞る

特定エンドポイント固有の上限(ログイン試行5回/分、AI推論20回/時など)は、route handlerの入口で個別に当てます。ルートごとの Ratelimit インスタンスを使い分けます。

// app/api/login/route.ts — ログイン経路に厳しい上限を個別適用
import { loginLimiter } from "@/lib/ratelimit";
import { getClientIp } from "@/lib/client-ip";
import { z } from "zod";

const Body = z.object({ email: z.string().email(), password: z.string().min(1) });

export async function POST(req: Request) {
  // 1) まずレート制限(重い認証処理に入る前に弾く)
  const ip = getClientIp(req);
  const { success, reset } = await loginLimiter.limit(`ip:${ip}`);
  if (!success) {
    const retry = Math.ceil((reset - Date.now()) / 1000);
    return Response.json(
      { error: "試行回数が上限に達しました。しばらく待って再試行してください。" },
      { status: 429, headers: { "Retry-After": String(retry) } },
    );
  }

  // 2) 入力検証(外部入力は境界で必ず Zod で絞る)
  const parsed = Body.safeParse(await req.json());
  if (!parsed.success) return Response.json({ error: "invalid" }, { status: 400 });

  // 3) 本処理(認証)。成功/失敗を漏らさないメッセージにする…
  // ブルートフォースをさらに抑えるなら、IP単位に加えてアカウント単位の制限も併用する
}

ポイントは順序です。レート制限は、入力検証や認証といった重い処理の前に置きます。攻撃者に高コストの処理を踏ませない——これがコスト型DoS対策の要諦です。

Retry-After は作法であり、優しさ。 429を返すときに Retry-After(秒数またはHTTP日付)を添えると、行儀の良いクライアント(自社のフロント、正規のボット、リトライ機構)はそれに従って待ちます。これにより無駄な再試行が減り、サーバー負荷もクライアント体験も改善します。RateLimit-* 系ヘッダーで残数を常時返せば、クライアントは上限に達する前に自制できます。

実運用では、レート制限・Origin検証・CSPなどの水平統制を middleware に集約することが多いです。Origin検証によるCSRF対策はServer Actions の CSRF / Origin 保護、CSPやセキュリティヘッダーの厳格化はCSP nonce とセキュリティヘッダー設計に切り出しています。これらは「一度書けば全リクエストに効く」典型的な水平統制で、レート制限と同じ層に属します。


8. 正直なスコープ——アプリ層のレート制限はDDoSの代わりにならない

ここは強調させてください。本記事のレート制限は「アプリケーション層(L7)」の濫用対策であって、ボリューム型のDDoS(L3/L4)を防ぐものではありません。

攻撃の種類守る層本記事の手法で防げるか
L7 濫用ログイン総当たり、OTP連打、API濫用、コスト型DoSアプリ層(route/middleware)✅ これが対象
L3/L4 ボリューム型DDoSSYNフラッド、UDPフラッド、帯域飽和ネットワーク/エッジ❌ エッジ/WAFの領域
L7 ボリューム型DDoS巨大なHTTPフラッドで関数を飽和させるエッジ/WAF+アプリ△ エッジで吸収し、アプリで補完

理由は単純です。アプリ層のレート制限が「拒否」を判断するには、リクエストがあなたの関数に到達して、Redisを1回叩く必要があります。秒間数百万の攻撃トラフィックが来れば、拒否を数える処理そのものが過負荷になり、Redisコストも跳ね上がります。ボリューム型DDoSは、関数に届く手前——ネットワークエッジで吸収しなければなりません。

だから正しい構えは併用です。

  • エッジ / WAF:ボリューム型DDoS、既知の悪性IP、ボット、L7フラッドを関数の手前で吸収する。
  • アプリ層レート制限(本記事):エッジを通り抜けた「正規に見えるが濫用的」なリクエスト——ブルートフォース、OTP濫用、コスト型DoS——を業務ロジックの近くで精密に絞る。

どちらか一方では不十分で、互いを代替しません。「レート制限を入れたからDDoSは大丈夫」は誤りです。 アプリ層の制限は、エッジの防御を補完するものであって、置き換えるものではない——この線引きを、発注者にもチームにも明示してください。なお、この「自動化できる水平統制」と「設計でしか守れない垂直リスク(認可/IDORなど)」をどこまで自前で、どこから専門家に委ねるかの判断軸はセキュリティ監査が必要になる範囲に整理しています。


9. 本番前チェックリスト

外注でもAI製でも、レート制限を本番に出す前に最低限これだけは確認してください。

  • 状態をプロセスメモリに置いていないnew Map() / モジュールスコープ変数でのカウントは不可)。共有ストア(Redis等)にある
  • カウントが全関数インスタンスから共有される(複数インスタンス前提で検証した)
  • read-modify-write がアトミック(Luaまたは検証済みライブラリ。素朴な GET→SET ではない)
  • スライディングウィンドウを使い、固定ウィンドウの境界バーストを避けている(厳密さが要る経路では特に)
  • キー設計がIP・ルート・ユーザーで適切に分かれ、重い処理ほど厳しい上限になっている
  • クライアントIPを信頼できるプロキシ経由でのみ取得している(生の x-forwarded-for 末尾を鵜呑みにしていない)
  • 拒否時に 429 + Retry-After を返し、RateLimit-* で残数を伝えている
  • レート制限を重い処理(認証・AI推論・外部API)の前に置いている
  • レート制限ストアの認証情報を NEXT_PUBLIC_ で公開していない(env のみ)
  • エッジ/WAFのDDoS対策を別途併用し、「アプリ層だけでDDoSを防げる」と誤解していない

発注者の視点で最も効くのは、**「レート制限のカウンタはどこに保存していますか?」**の一問です。「メモリです」「Map です」という答えなら、サーバーレスでは効いていない可能性が高い。良い開発者は「Redis等の共有ストアに、アトミックに」と即答できます。


10. まとめ:状態をプロセスの外へ出せば、レート制限は正しく効く

要点を整理します。

  • サーバーレス(Vercel / Lambda)の関数は使い捨て・複数同時・コールドスタートで初期化されるため、プロセス内 Map のレート制限は本番のスケール下で必ず壊れる。ローカルで動くことは反証にならない。
  • 正解は状態をプロセスの外(共有ストア)に置くこと。全インスタンスから見える単一のRedis等にカウンタを集約する。
  • 固定ウィンドウは境界バーストで上限の2倍を通す。多くの場合は**スライディングウィンドウ(近似で十分)**を既定にする。
  • 並行リクエスト下で正しく数えるにはアトミックな read-modify-write が必須。Luaを手書きするより @upstash/ratelimit のような検証済みの分散レート制限器を使う。
  • キーはIP・ルート・ユーザーで設計し、IPは信頼できるプロキシ経由でのみ取得する。拒否時は 429 + Retry-After を返す。これはOWASP API4:2023(Unrestricted Resource Consumption) が指す濫用・コスト暴走への対策。
  • 正直に言うと、アプリ層のレート制限はL3/L4のボリューム型DDoSの代わりにはならない。 それはエッジ/WAFの領域で、両方を併用する。「入れたから安全」という製品も手法も存在しない。

この「サーバーレスで正しく効くレート制限」のような水平統制の実装は、私が公開しているOSS Aegis が支援する領域です。ヘッダー/CSP・レート制限・CSRF・型付きenvといったアプリ横断で一律に効く統制をドロップインで固め、npx @aegiskit/cli scan で現状を可視化します。ただし正直に言えば、Aegisは水平統制の実装を助け、認可/IDORのような垂直リスクを検出・警告するところまでで、「完全に安全」にする魔法ではありません。レート制限の設計レビューや、サーバーレスNext.jsアプリ全体の堅牢化が必要であればセキュリティ監査で承ります。私自身、環境分野のサーバーレス決済プラットフォームで、従量課金・高負荷の決済経路における信頼性レイヤー(リトライ・冪等性・流量制御)を実運用で設計してきました。

AIで速く作ること自体は正しい。速く作ったものを、漏らさず正しく効かせる——その設計や検証が必要であれば、お気軽にご相談ください。


よくある質問(FAQ)

Q. Upstash以外でもいいですか? A. 構いません。要件は「全関数インスタンスから見える共有ストアで、アトミックに数えられること」だけです。Vercel KV、自前のRedis/Valkey、Memcached(CASを使う)などが候補です。重要なのは製品名ではなく、プロセスメモリの外にあり、read-modify-write が原子的であること。@upstash/ratelimit はサーバーレスからHTTPで叩ける手軽さで広く使われていますが、本質は共有+アトミック性です。

Q. 固定ウィンドウではダメですか? A. 「ダメ」ではなく「境界バーストを許容できるか」次第です。緩い全体制限なら固定ウィンドウでも実害は小さい。一方、ログイン試行・OTP送信・課金処理のように「短時間に集中させたい攻撃」を相手にする経路では、境界で2倍通るのは無視できません。迷ったらスライディングウィンドウにしておくのが安全です。

Q. レート制限を入れればDDoSは防げますか? A. いいえ。第8節のとおり、アプリ層のレート制限はL7の濫用(ブルートフォース・OTP濫用・コスト型DoS)に効きますが、L3/L4のボリューム型DDoSは関数に届く手前のエッジ/WAFで吸収する領域です。両方が必要で、片方がもう片方を代替することはありません。「レート制限=DDoS対策」という理解は危険です。

Q. middleware と route handler、どちらに置くべきですか? A. 併用が定石です。middlewareは全リクエストの前段で走るので「横断的な制限」「重い処理に到達する前に弾く」のに向きます。route handlerは「ログインは5回/分、AI推論は20回/時」のようなエンドポイント固有の上限に向きます。横断的な防衛線をmiddlewareに、精密な制限をroute handlerに、と役割で分けてください。

Q. クライアントIPはどう取るのが正解ですか? A. デプロイ構成によります。Vercel上では改ざんできない x-real-ip を信頼します。前段に自前プロキシ(Nginx/ALB)がいる場合は「信頼できるプロキシの数」を把握し、x-forwarded-for の右からN番目を採ります。共通原則は**「信頼できるプロキシが付けたヘッダー以外をIPの根拠にしない」**。生の x-forwarded-for 末尾を鵜呑みにすると、攻撃者がヘッダーを偽装してキーを変え、上限を回避できます。

Q. 認証前と認証後で、キーは変えるべきですか? A. はい。未ログイン経路(ログイン試行・公開API)はIP単位が主軸になりますが、NAT配下の巻き添えに注意します。認証後は、検証済みのユーザーIDを主軸にするのが正確で、巻き添えも避けられます。特にコスト型DoS(重い処理の連打)は、ユーザー単位で絞るのが最も効果的です。


参考資料

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の対策、ツールで自動化できます

Next.js / Supabase のセキュリティ統制を、OSS の Aegis で自動化

この記事の対策の多くは、ミドルウェア1枚と静的解析で機械的に検出・強化できます。無料・MIT の Aegis なら、いまのプロジェクトを1コマンドからスキャンできます。設計が要る「縦のリスク」は監査でも承ります。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。