# フォーム送信にレート制限をかける方法

> 共有ストアで訪問者ごとにフォーム送信のレート制限をかけ、Retry-After 付きの 429 を返し、1 つの IP を共有するオフィスを締め出さないようにします。

公開日 2026-10-25 · https://ikiform.com/ja/guides/spam-protection/rate-limiting

すべてのサーバーインスタンスが共有する Redis などのストアで、訪問者の IP ごとに送信数を数え、小さなバーストを許し、上限に達したら `Retry-After` ヘッダー付きで `429` を返します。メモリ内のカウンターが機能するのは、長時間動き続ける 1 台のサーバーだけで、サーバーレスのホストでは機能しません。上限は実際の人がフォームをどう使うかから決め、多くの人が 1 つの IP を共有するオフィスやイベントのための余裕を残してください。

## レート制限ならではの違い

レート制限はスパムと本物のメールを区別しません。止めるのは量です。ループで送信するスクリプト、フォームを繰り返し回るボット、ボタンを連打する人などです。そのため、ボディを読む前に動くので最もコストが低い層であり、攻撃者と忙しいオフィスを区別できないので最も大ざっぱな層でもあります。役に立つか害になるかは、3 つのこと、つまりカウントをどこに置くか、何を単位に数えるか、許容量がどれだけ速く戻るかで決まります。

図: 1 人の訪問者からの連続送信と、なめらかな訪問者ごとの制限

1. 訪問者が 10 秒以内に 3 件送信します。すべて受け付けられます。
2. 次の 1 件は、どれだけ待てばよいかを示す Retry-After ヘッダー付きの 429 で拒否されます。
3. その後は 12 秒ごとに 1 件ずつ送信できます。
4. まるまる 1 分間送信がなければ、許容量はすべて戻ります。

これは Ikiform の Standard の数値で、1 分あたり 5 件、10 秒あたり最大 3 件です。なめらかに補充されるので、あと 1 件送るために期間全体を待つ人はいません。

## カウントを置く場所

| ストア | 動く環境 | 注意点 |
| --- | --- | --- |
| メモリ内の `Map` | 長時間動く 1 台のサーバー | 再起動で消える。サーバーレスのインスタンスはそれぞれ別の Map を持つので、実際の上限はわからない |
| Upstash などの Redis | サーバーレスやエッジを含むすべて | 送信ごとにネットワーク呼び出しが 1 回。サーバーに近いリージョンを選ぶ |
| Cloudflare Workers のレート制限バインディング | Workers | おおよその値で、Cloudflare のロケーションごとにカウント。期間は 10 秒か 60 秒のみ |
| nginx の `limit_req` などのリバースプロキシ | 自前のプロキシの背後にあるサーバー | プロキシごとにカウント。実際のクライアント IP が必要 |

アルゴリズムはストアほど重要ではありませんが、訪問者の体感を左右します。固定ウィンドウは分の区切りでリセットされるので、スクリプトはある 1 分の終わりに許容量をすべて使い、次の 1 分の始めにまた使い切れます。スライディングウィンドウは前のウィンドウを加味してこの境目をならします。トークンバケットや、Ikiform が使う同様のアルゴリズムは 1 件ずつ補充するので、入力ミスをした人は数秒後に再送でき、バーストを許すこともありません。お問い合わせフォームなら、数値が人の使い方に合っていればどれでも機能します。

以下の例では Upstash の `@upstash/ratelimit` を使います。どのフレームワークでもサーバーレスのホストでも同じように動くからです。`UPSTASH_REDIS_REST_URL` と `UPSTASH_REDIS_REST_TOKEN` を設定し、ヘルパーを 1 回保存します:

`lib/limit.ts`:

```ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const contact = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  prefix: "contact",
});

export async function allow(ip: string | null | undefined) {
  const { success, reset } = await contact.limit(ip || "no-ip");
  return { ok: success, retryAfter: Math.max(1, Math.ceil((reset - Date.now()) / 1000)) };
}
```

`reset` はミリ秒単位の Unix 時刻なので、ヘルパーは `Retry-After` 用にそれを整数の秒に変換します。IP のない送信は 1 つのバケットを共有するので、ヘッダーがないことが素通りの抜け道になることはありません。

## フレームワークに適用する

**HTML**

プレーンな HTML フォームには何かを数えるサーバーがありません。Ikiform に送信し、フォームでプリセットを選んでください。上限を超えた訪問者には、エラーではなく短い「速度を落としてください」ページが表示されます。

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button>Send</button>
</form>
```

**Next.js**

サーバーアクションの冒頭で、他の処理より先に上限を確認します。

`app/contact/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { allow } from "@/lib/limit";

export async function sendMessage(_previous: { message: string }, formData: FormData) {
  const forwarded = (await headers()).get("x-forwarded-for");
  const limit = await allow(forwarded?.split(",")[0]?.trim());
  if (!limit.ok) return { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
  await saveMessage(Object.fromEntries(formData));
  return { message: "Thanks. We'll reply by email." };
}
```

**Astro**

オンデマンドのページでは、`context.clientAddress` で訪問者の IP を取得できます。`TOO_MANY_REQUESTS` は `429` に対応します。

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro/zod";
import { allow } from "../lib/limit";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({ email: z.email(), message: z.string().min(1) }),
    handler: async (input, context) => {
      const limit = await allow(context.clientAddress);
      if (!limit.ok) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
      }
      await saveMessage(input);
      return { sent: true };
    },
  }),
};
```

**TanStack Start**

サーバー関数からステータスと `Retry-After` を設定し、`fetch` の呼び出し側が読めるようにします。

`src/server/send-message.ts`:

```ts
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeader, setResponseHeaders, setResponseStatus } from "@tanstack/react-start/server";
import { allow } from "../lib/limit";

export const sendMessage = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return { email: String(data.get("email") ?? ""), message: String(data.get("message") ?? "") };
  })
  .handler(async ({ data }) => {
    const limit = await allow(getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim());
    if (!limit.ok) {
      setResponseStatus(429);
      setResponseHeaders(new Headers({ "Retry-After": String(limit.retryAfter) }));
      return { ok: false, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
    }
    await saveMessage(data);
    return { ok: true, message: "Thanks. We'll reply by email." };
  });
```

**React Router**

`data()` を使うと、Action はフォーム用のメッセージと一緒に、ヘッダー付きの `429` を返せます。

`app/routes/contact.tsx`:

```ts
import { data } from "react-router";
import { allow } from "../lib/limit";
import type { Route } from "./+types/contact";

export async function action({ request }: Route.ActionArgs) {
  const limit = await allow(request.headers.get("x-forwarded-for")?.split(",")[0]?.trim());
  if (!limit.ok) {
    return data(
      { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` },
      { status: 429, headers: { "Retry-After": String(limit.retryAfter) } },
    );
  }
  await saveMessage(Object.fromEntries(await request.formData()));
  return { message: "Thanks. We'll reply by email." };
}
```

**SvelteKit**

`getClientAddress()` はアダプターから IP を読みます。`fail(429)` はメッセージ付きでフォームをページに残します。ヘルパーは `src/lib/limit.ts` として保存します。SvelteKit 3 では `package.json` の `#lib` エイリアス経由でインポートします。

`src/routes/contact/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { allow } from "#lib/limit.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress }) => {
    const limit = await allow(getClientAddress());
    if (!limit.ok) return fail(429, { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
    await saveMessage(Object.fromEntries(await request.formData()));
    return { sent: true };
  },
} satisfies Actions;
```

**Nuxt**

`getRequestIP` に `xForwardedFor` を付けると、プロキシの背後でもクライアントの IP を読めます。ヘルパーを `server/utils` に保存すると自動でインポートされます。

`server/api/contact.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const limit = await allow(getRequestIP(event, { xForwardedFor: true }));
  if (!limit.ok) {
    setResponseHeader(event, "Retry-After", limit.retryAfter);
    throw createError({ status: 429, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
  }
  await saveMessage(await readBody(event));
  return { sent: true };
});
```

**React**

シングルページアプリは上限を強制できませんが、尊重すべきです。もう一度ボタンを押させるのではなく、`Retry-After` を読んでカウントダウンを表示してください。`429 quota_exceeded` には `Retry-After` がないので、エラーメッセージの処理に回ります。

`contact.js`:

```js
const button = form.querySelector("button");
const status = form.querySelector("[role=status]");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(form),
  });
  const retryAfter = response.headers.get("Retry-After");
  if (response.status === 429 && retryAfter) {
    let wait = Number(retryAfter);
    button.disabled = true;
    const timer = setInterval(() => {
      status.textContent = `Too many messages. Try again in ${wait} seconds.`;
      wait -= 1;
      if (wait < 0) {
        clearInterval(timer);
        button.disabled = false;
        status.textContent = "";
      }
    }, 1000);
    return;
  }
  const result = await response.json();
  status.textContent = result.ok ? "Thanks. We'll reply by email." : result.error.message;
});
```

**Vue**

Vue コンポーネントでも処理は同じです。`Retry-After` を読み、ボタンを無効にし、ref でカウントダウンします。

`src/components/ContactForm.vue`:

```vue
<script setup lang="ts">
import { onBeforeUnmount, ref } from "vue";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const wait = ref(0);
const notice = ref("");
let timer: ReturnType<typeof setInterval> | undefined;

function countDown(seconds: number) {
  wait.value = seconds;
  clearInterval(timer);
  timer = setInterval(() => {
    wait.value -= 1;
    if (wait.value <= 0) clearInterval(timer);
  }, 1000);
}

async function send(event: SubmitEvent) {
  notice.value = "";
  const response = await fetch(ENDPOINT, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(event.target as HTMLFormElement),
  });
  const retryAfter = Number(response.headers.get("Retry-After"));
  if (response.status === 429 && retryAfter > 0) {
    countDown(retryAfter);
    return;
  }
  const result = await response.json();
  notice.value = result.ok ? "Thanks. We'll reply by email." : result.error.message;
}

onBeforeUnmount(() => clearInterval(timer));
</script>

<template>
  <form :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message" required />
    <p role="status">{{ wait > 0 ? `Too many messages. Try again in ${wait} seconds.` : notice }}</p>
    <button :disabled="wait > 0">Send</button>
  </form>
</template>
```

Cloudflare Workers では、組み込みのバインディングが最も安価な選択肢です:

`wrangler.toml`:

```toml
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"

  [ratelimits.simple]
  limit = 5
  period = 60
```

`src/index.ts`:

```ts
const ip = request.headers.get("CF-Connecting-IP") ?? "no-ip";
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
if (!success) return new Response("Too many messages", { status: 429, headers: { "Retry-After": "60" } });
```

Cloudflare のドキュメントによると、このバインディングはおおよその値で、ロケーションごとに別々にカウントされるので、分散した攻撃では上限を超えることがあります。ロケーションをまたいだ正確な上限には、Cloudflare は Durable Object を推奨しています。自前の nginx の背後なら、アプリが送信を受け取る前に `limit_req` がカウントします:

`nginx.conf`:

```text
limit_req_zone $binary_remote_addr zone=contact:10m rate=5r/m;

server {
  location = /api/contact {
    limit_req zone=contact burst=3 nodelay;
    limit_req_status 429;
    proxy_pass http://127.0.0.1:3000;
  }
}
```

## 何を単位に数えるか

- **IP とフォームの組み合わせ。** 通常の選択です。1 人の訪問者がこのフォームを大量送信で埋めることはできず、サイトの別のフォームを使っても同じ許容量は消費されません。
- **多くの人と 1 つの IP。** オフィス、学校、携帯キャリア、カンファレンスの Wi-Fi では、多くの人が 1 つのアドレスの背後にいます。IP ごとの上限はそうした人たちに十分なほど緩くするか、イベントの日には引き上げてください。
- **実際のクライアント IP。** プロキシや CDN の背後では、ホストが設定するヘッダーを読み、それだけを信頼してください。生の `X-Forwarded-For` の値はクライアントが設定できます。
- **自分のサーバーの IP。** サーバーが送信を別のサービスに転送すると、転送先ではすべての訪問者がサーバーの IP を共有します。転送する前に、自分の側で訪問者の IP ごとに制限してください。

## Ikiform のレート制限

Ikiform はボディを読む前に、訪問者の IP とフォームごとにすべての送信を正確に数え、固定ウィンドウではなくなめらかに補充します。各フォームは **Rate limit** でプリセットを選びます。それらより前に、全フォーム合計で IP ごとに 1 分あたり 600 件の大量送信ガードが動きます。

| プリセット | 訪問者ごと | 向いている用途 |
| --- | --- | --- |
| Standard | 1 分あたり 5 件、10 秒あたり最大 3 件 | ほとんどのフォーム |
| Relaxed | 1 分あたり 20 件 | 共有 Wi-Fi でのイベント、ログイン後のフォーム |
| Strict | 10 分あたり 2 件、5 分ごとに 1 件追加 | お問い合わせや営業のフォーム |
| Custom | 10 秒、1 分、10 分、1 時間、1 日あたり 1〜1,000 件 | 1 人 1 回のエントリー、キオスク端末 |

Custom で上限を設定しない限り、フォーム全体でも全訪問者合計で 1 分あたり 300 件までです。上限を超えると、ブラウザーからの送信には「速度を落としてください」ページが表示され、`fetch` には `Retry-After` 付きの `429 rate_limited` が返ります。Ikiform はこのヘッダーを CORS で公開しているので、上のコードから読めます。補充の仕組みは[レート制限のドキュメント](https://ikiform.com/ja/docs/rate-limits.md#refill)で説明しています。

> サーバー側の版はシークレットキーで送信するので、Ikiform は **Allowed domains** と自身の人間確認をスキップします。すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分 5 件という Standard の制限は全訪問者の合計で数えられます。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

## 動作を確認する

1. フォームを続けて何度か送信します。上限を超えると、`Retry-After` ヘッダー付きの `429` か、Ikiform の「速度を落としてください」ページが返ります。
2. `Retry-After` の秒数だけ待ってから、もう一度送信します。受け付けられます。
3. Wi-Fi を切ったスマートフォンなど、別のネットワークから送信します。こちらには別の許容量があります。
4. サーバーが送信を転送する場合は、2 台の端末からすばやく送信し、互いのカウントを共有していないことを確認します。

## ハマりどころ

- **拒否された送信で許容量を消費しないこと。** 処理の前に数え、拒否された送信でウィンドウを先延ばしにしないでください。そうしないと、焦った訪問者が自分で自分を締め出してしまいます。
- **ページの読み込みは制限しないこと。** フォームの表示回数ではなく送信を数えてください。そうしないと、再読み込みしただけで送信の枠を使い切ってしまいます。
- **`429 rate_limited`** 1 人の訪問者が、フォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件です。`Retry-After` の時間だけ待つか、1 つの共有 IP から送信されるフォームには Relaxed か Custom を選んでください。
- **IPv6。** 1 人が多数の IPv6 アドレスを持てます。厳密な上限にするなら、アドレス全体ではなく `/64` のプレフィックスで数えてください。

## よくある質問

### 1 分あたり何件のフォーム送信を許可すべきですか？

お問い合わせフォームなら、訪問者ごとに数件で十分です。Ikiform の Strict プリセットは 10 分あたり 2 件です。共有ネットワークから使われる登録フォームやイベントのフォームには、1 分あたり 20 件程度が必要です。

### Vercel や Netlify でメモリ内のレート制限は使えますか？

確実には使えません。サーバーレスのインスタンスはそれぞれ独自のメモリを持ち、生まれては消えるので、カウントがランダムにリセットされます。Redis などの共有ストアを使ってください。

### レート制限にかかったフォームには何を表示すべきですか？

Retry-After ヘッダーから取った待ち時間を示すシンプルなメッセージを表示し、それまで送信ボタンを無効にします。入力された内容は消さないでください。

### Ikiform はデフォルトでフォームにレート制限をかけますか？

はい。どのフォームも最初は Standard プリセット（訪問者ごとに 1 分あたり 5 件）で、フォームごとに Relaxed、Strict、Custom に切り替えられます。

## その他のガイド

- [お問い合わせフォームのスパムを止める](https://ikiform.com/ja/guides/spam-protection.md): 本物の訪問者に負担をかけない層でお問い合わせフォームのスパムを止めます。ハニーポット、時間、内容のスコア、レート制限、必要なときだけの人間確認です。
- [CAPTCHA なしで](https://ikiform.com/ja/guides/spam-protection/without-captcha.md): CAPTCHA をまったく使わずにフォームスパムの大半を防ぐ方法。ハニーポット、署名付きの表示時刻、内容のスコア、レート制限を単独ではなく合算して使います。
- [ハニーポットフィールド](https://ikiform.com/ja/guides/spam-protection/honeypot.md): スクリーンリーダー、キーボード、自動入力の邪魔をせずにボットを捕まえるハニーポットを、各スタックのサーバー側チェックとあわせてどんなフォームにも追加します。
- [静的サイト](https://ikiform.com/ja/guides/spam-protection/static-site.md): シークレットを保持できず送信数も数えられない静的サイトのフォームでも、ハニーポットのパーシャル、安全なスクリプト、サーバー側のチェックでスパムを防ぎます。
- [Cloudflare Turnstile](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md): プレーンな HTML フォームに Cloudflare Turnstile を追加します。ウィジェット、サーバー側の siteverify、テスト用キー、自前のキーやサーバーなしで動かす方法まで。
- [reCAPTCHA の代替](https://ikiform.com/ja/guides/spam-protection/recaptcha-alternatives.md): Turnstile や hCaptcha から Proof of Work、CAPTCHA なしまで、フォーム向け reCAPTCHA の代替を比較し、小さく安全な変更で乗り換えます。
