# Turnstile を使った Nuxt フォームのスパム対策

> Nuxt のフォームをスパムから守る方法。Turnstile 付きの Nitro サーバールート、Nitro ストレージでの IP ごとの制限、runtimeConfig のシークレット、routeRules で設定する CSP。

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

フォームを `$fetch` でサーバールートに送信します。サーバールートは Nitro ストレージでクライアント IP ごとの試行回数を数え、siteverify で Turnstile のトークンを検証し、ハニーポットと一緒にフィールドを転送します。シークレットは非公開の `runtimeConfig` に置き、実行時に `NUXT_` の環境変数から設定します。お問い合わせページの CSP は `routeRules` から送り、スクリプトとその iframe のために `challenges.cloudflare.com` を許可します。

## Nuxt ならではの違い

Nuxt のサーバーは Nitro で、Nitro はスパムチェックに必要なものを 2 つ備えています。`server/api` のサーバールートは `$fetch` があらかじめつながった素のエンドポイントで、`useStorage()` は環境ごとにバックエンドを選べるキーバリューストアです。開発ではメモリ、本番では Redis などのドライバーを使えます。そのため、レート制限は依存パッケージの追加ではなく設定の変更で済みます。

ここでは Nuxt の 2 つの習慣が問題になります。`getRequestIP(event, { xForwardedFor: true })` は `X-Forwarded-For` の最初の値を返しますが、h3 自身のドキュメントはこれをクライアントの入力と呼んでいます。また、本番では `.env` ファイルが読まれないので、シークレットはプラットフォームの環境変数として設定する必要があります。

図: Nuxt のサーバールートと Nitro ストレージを通るお問い合わせの送信

1. コンポーネントがフィールドと Turnstile のトークンを $fetch で /api/contact に送信します。
2. サーバールートは、runtimeConfig で指定したヘッダーからクライアント IP を読みます。
3. Nitro ストレージで、その IP の現在の 1 分間の送信数を数えます。5 回を超えたら、残り秒数付きで 429 を投げます。
4. 上限内なら、siteverify でトークンを検証し、action とホストを確認します。
5. 次にフィールドを Ikiform に転送します。Ikiform が保存し、ハニーポットを評価します。

開発中のストレージのマウントはメモリ上です。本番では nuxt.config が別のドライバーをマウントするので、同じコードが Redis とやり取りします。

## プロジェクトの準備

`ファイル`:

```text
app/
  components/
    ContactForm.vue
  composables/
    useTurnstile.ts
server/
  api/
    contact.post.ts
  utils/
    spam.ts
nuxt.config.ts
.env
```

`.env`:

```bash
NUXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
NUXT_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_CLIENT_IP_HEADER=x-forwarded-for
```

`nuxt.config.ts`:

```ts
const policy = [
  "default-src 'self'",
  "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
  "frame-src https://challenges.cloudflare.com",
  "object-src 'none'",
  "base-uri 'self'",
].join("; ");

export default defineNuxtConfig({
  runtimeConfig: {
    turnstileSecretKey: "",
    ikiformSecretKey: "",
    clientIpHeader: "x-forwarded-for",
    public: {
      turnstileSiteKey: "",
    },
  },
  routeRules: {
    "/contact": { headers: { "Content-Security-Policy": policy } },
  },
  $production: {
    nitro: {
      storage: {
        ratelimit: { driver: "upstash" },
      },
    },
  },
});
```

- `runtimeConfig` のキーはすべて、実行時に `NUXT_` の変数で上書きできます。`turnstileSecretKey` は `NUXT_TURNSTILE_SECRET_KEY` で、`public.turnstileSiteKey` は `NUXT_PUBLIC_TURNSTILE_SITE_KEY` で上書きします。`public` の外にあるキーはブラウザーに届きません。
- `routeRules` は `/contact` でだけ CSP を送ります。Nuxt はペイロードとハイドレーション用のスクリプトをインラインで出力するので、このポリシーでは `'unsafe-inline'` を残しています。より厳しいポリシーが必要なら、コミュニティの `nuxt-security` モジュールで nonce を生成できます。
- `upstash` ドライバーは本番でだけマウントされ、環境から `UPSTASH_REDIS_REST_URL` と `UPSTASH_REDIS_REST_TOKEN` を読みます。`@upstash/redis` パッケージが必要なので、`npm install @upstash/redis` でインストールしてください。

## サーバーのユーティリティ

`server/utils` のファイルは、すべてのサーバールートに自動でインポートされます:

`server/utils/spam.ts`:

```ts
import type { H3Event } from "h3";

const WINDOW_SECONDS = 60;
const LIMIT = 5;

export function clientIp(event: H3Event) {
  const header = useRuntimeConfig(event).clientIpHeader;
  return getRequestHeader(event, header)?.split(",")[0]?.trim() || "unknown";
}

export async function secondsToWait(ip: string) {
  const storage = useStorage("ratelimit");
  const window = Math.floor(Date.now() / 1000 / WINDOW_SECONDS);
  const key = `contact:${ip}:${window}`;
  const count = ((await storage.getItem<number>(key)) ?? 0) + 1;
  await storage.setItem(key, count);
  if (count <= LIMIT) return 0;
  return (window + 1) * WINDOW_SECONDS - Math.floor(Date.now() / 1000);
}

export async function isHuman(event: H3Event, token: string, ip: string) {
  if (!token || token.length > 2048) return false;
  try {
    const verdict = await $fetch<{ success: boolean; hostname?: string; action?: string }>("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      body: { secret: useRuntimeConfig(event).turnstileSecretKey, response: token, remoteip: ip },
    });
    return verdict.success && verdict.action === "contact" && (import.meta.dev || verdict.hostname === getRequestHost(event));
  } catch {
    return false;
  }
}
```

- `clientIp` が読むのは、ホストが上書きするヘッダー 1 つだけです。`NUXT_CLIENT_IP_HEADER` を、Vercel では `x-forwarded-for`、Netlify では `x-nf-client-connection-ip`、Cloudflare では `cf-connecting-ip` に設定します。
- カウンターは Nitro ストレージ上の固定ウィンドウです。読み取りと書き込みがアトミックではないので、同時に来た 2 つの送信がどちらも上限をすり抜けることがありますが、お問い合わせフォームなら許容範囲です。キーは自動では期限切れにならないので、Redis のデータベースに削除ポリシーを設定してください。
- `$fetch` は siteverify のボディを JSON で送り、siteverify はそれを受け付けます。ホストのチェックは `getRequestHost` を使い、開発中はスキップします。テスト用のトークンにはダミーのホスト名が入っているためです。

## サーバールート

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

```ts
interface ContactBody {
  name?: string;
  email?: string;
  company?: string;
  message?: string;
  _gotcha?: string;
  "cf-turnstile-response"?: string;
}

export default defineEventHandler(async (event) => {
  const ip = clientIp(event);
  const wait = await secondsToWait(ip);
  if (wait) throw createError({ status: 429, message: `Too many messages. Try again in ${wait} seconds.` });

  const body = await readBody<ContactBody>(event);
  const fields = {
    name: body.name?.trim().slice(0, 120) ?? "",
    email: body.email?.trim() ?? "",
    company: body.company?.trim().slice(0, 120) ?? "",
    message: body.message?.trim().slice(0, 5000) ?? "",
    _gotcha: body._gotcha ?? "",
  };
  if (!fields.email.includes("@")) throw createError({ status: 400, message: "Enter an address we can reply to." });
  if (!(await isHuman(event, body["cf-turnstile-response"] ?? "", ip))) {
    throw createError({ status: 403, message: "We couldn't confirm you're human. Try again." });
  }

  const origin = getRequestHeader(event, "origin");
  await $fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: fields,
  }).catch((failure) => {
    throw createError({
      status: failure.status ?? 502,
      message: failure.data?.error?.message ?? "Your message didn't send. Try again.",
    });
  });
  return { sent: true };
});
```

- `.post.ts` の接尾辞により、Nitro は `POST` にだけ応答します。それ以外には `405` が返ります。
- 制限は `readBody` より前に実行されるので、大量送信でボディの解析や siteverify の呼び出しのコストがかかることはありません。
- 失敗するたびに `createError` を投げます。JSON のボディに `message` が入り、コンポーネントがそれを表示します。
- 訪問者の `Origin` を渡します。サーバー側の fetch はこれを送らず、Ikiform は無料の `localhost` テストを判定するためにこれを読むからです。
- ハニーポットの `_gotcha` はそのまま渡すので、入力されていれば Ikiform は破棄せずにスパムとして保存します。

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

## コンポーザブルとフォーム

`app/composables/useTurnstile.ts`:

```ts
interface TurnstileApi {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

let loading: Promise<TurnstileApi> | undefined;

function load() {
  loading ??= new Promise<TurnstileApi>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
    script.async = true;
    script.onload = () => {
      const api = (window as Window & { turnstile?: TurnstileApi }).turnstile;
      if (api) resolve(api);
      else reject(new Error("Turnstile did not load"));
    };
    script.onerror = () => reject(new Error("Turnstile did not load"));
    document.head.append(script);
  });
  return loading;
}

export function useTurnstile(action: string) {
  const { turnstileSiteKey } = useRuntimeConfig().public;
  const box = ref<HTMLElement>();
  const token = ref("");
  let api: TurnstileApi | undefined;
  let widgetId: string | undefined;

  onMounted(async () => {
    api = await load();
    if (!box.value) return;
    widgetId = api.render(box.value, {
      sitekey: turnstileSiteKey,
      action,
      callback: (value) => (token.value = value),
      "expired-callback": () => (token.value = ""),
    });
  });

  onBeforeUnmount(() => {
    if (widgetId) api?.remove(widgetId);
  });

  function reset() {
    token.value = "";
    if (widgetId) api?.reset(widgetId);
  }

  return { box, token, reset };
}
```

`app/components/ContactForm.vue`:

```vue
<script setup lang="ts">
const fields = reactive({ name: "", email: "", company: "", message: "", _gotcha: "" });
const { box, token, reset } = useTurnstile("contact");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");

async function send() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/contact", { method: "POST", body: { ...fields, "cf-turnstile-response": token.value } });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send your message. Try again.";
    status.value = "idle";
    reset();
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="fields.name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" v-model="fields.email" type="email" autocomplete="email" required />
    <label for="company">Company <span>(optional)</span></label>
    <input id="company" v-model="fields.company" autocomplete="organization" maxlength="120" />
    <label for="message">Message</label>
    <textarea id="message" v-model="fields.message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" v-model="fields._gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="box" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending' || !token">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

- コンポーザブルは `onMounted` でしか実行されないので、サーバーレンダリングで `window` に触れることはありません。
- ボタンはトークンを待ち、失敗するたびに `reset()` で新しいトークンを要求します。トークンは 1 回しか使えず、有効期間は 300 秒だからです。
- このフォームには JavaScript が必要です。JavaScript なしで送信できる版は、次のセクションを参照してください。
- `.hp` クラスは、[ハニーポットのガイド](https://ikiform.com/ja/guides/spam-protection/honeypot.md) と同じようにハニーポットを画面外に隠します。

## ローカルでテストする

開発中の `ratelimit` のマウントはメモリ上なので、`nuxt dev` を再起動するとカウントがリセットされます。ブラウザーなしでも制限の動作を確認できます。トークンのない送信は siteverify で `403` になりますが、それでも 1 回ずつカウントされます:

`ターミナル`:

```bash
for i in 1 2 3 4 5 6; do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/contact \
    -H "Content-Type: application/json" -d '{"email":"test@example.com"}'
done
```

最初の 5 行は `403` を、6 行目は `429` を出力します。ローカルでは IP ヘッダーを設定するプロキシがないので、すべてのリクエストが `unknown` として数えられます。これは想定どおりで、ヘッダーをコードではなく設定に置いているのはこのためです。

## デプロイ

| ホスト | NUXT_CLIENT_IP_HEADER | ストレージのドライバー |
| --- | --- | --- |
| Vercel | `x-forwarded-for` | Vercel Marketplace の `upstash` |
| Netlify | `x-nf-client-connection-ip` | `upstash` か `redis` |
| Cloudflare | `cf-connecting-ip` | `upstash`。Workers KV は結果整合性なので、カウントが遅れる |
| Node サーバー | プロキシが設定するヘッダー | `redis` か、単一インスタンスならメモリ |

`NUXT_` の変数はすべてホストのダッシュボードで設定します。Nuxt はサーバーの起動時に読むので、キーを変えたら再ビルドではなく再起動が必要です。本番とプレビューのホスト名をすべて Turnstile ウィジェットに追加してください。

## チェックを Ikiform に任せる

サーバールートがなければ、お問い合わせページも `nuxt generate` のサイトの他のページと一緒にプリレンダーできます。`method="post"` と、エンドポイントの URL を設定した `action` で、通常のフォームの送信先を Ikiform のエンドポイントにし、`server/api/contact.post.ts` とコンポーザブルを削除します。

フォームの **Spam check** を **Ikiform** に設定します。トークンのない送信には ikiform.com 上で Turnstile を実行してフィールドを送り届ける短い確認ページが表示されるので、CSP に Cloudflare の項目は不要です。**Rate limit** で訪問者ごとに 10 分あたり 2 回の Strict を選び、**Allowed domains** にドメインを登録します。Ikiform は捕まえたスパムを、判定理由とともに 30 日間保持します。エンドポイントとキーについては [Nuxt の設定ガイド](https://ikiform.com/ja/docs/guides/nuxt.md)を参照してください。

## 動作を確認する

1. テスト用のキーで `nuxt dev` を実行し、メッセージを送ります。**Submissions** に表示されます。プランのないワークスペースでも、`localhost` からのテストを月 50 件まで受け付けます。
2. 1 分以内に 6 通のメッセージを送ります。6 通目では残りの秒数が表示されます。
3. `NUXT_TURNSTILE_SECRET_KEY` を常に失敗するシークレットにして再起動します。どの送信でも人間確認のエラーが表示されます。
4. `nuxt build` の後、`curl -I` で `/contact` をリクエストします。レスポンスに `Content-Security-Policy` が含まれています。
5. ハニーポットを `_gotcha=bot` と埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。

## ハマりどころ

| 問題 | 対処 |
| --- | --- |
| 本番でシークレットが空になる | 本番では実行時に `.env` が読まれません。`NUXT_TURNSTILE_SECRET_KEY` などはホストの環境で設定してください。 |
| デプロイやインスタンスごとに制限がリセットされる | ストレージのマウントがまだメモリ上です。`$production.nitro.storage` が共有ドライバーをマウントし、その認証情報が設定されているか確認してください。 |
| スクリプトがヘッダーを変えて制限をすり抜ける | そのヘッダーを上書きしないホストで、`xForwardedFor: true` 付きの `getRequestIP` を読んでいます。`clientIp` でホスト自身のヘッダーを読んでください。 |
| サーバーレンダリング中に `window is not defined` が出る | Turnstile のコードが `onMounted` の外で実行されました。`window` へのアクセスはすべてコンポーザブルのライフサイクルフックの中に置いてください。 |

## よくある質問

### Nuxt のフォームに Turnstile を追加するには？

onMounted でウィジェットを明示的に描画し、トークンを ref に保持して、フィールドと一緒にサーバールートに送信します。ルートは runtimeConfig のシークレットキーと一緒にトークンを siteverify に送り、成功した場合にだけ処理を続けます。

### Nuxt のサーバールートにレート制限をかけるには？

Nitro の useStorage でクライアント IP ごとのリクエストを数えます。本番では Redis か Upstash にマウントして、すべてのインスタンスでカウントを共有します。カウントが上限を超えたら、createError で 429 を投げます。

### Nuxt でレート制限に getRequestIP を使っても安全ですか？

クライアント自身の X-Forwarded-For をそのまま通すホストで xForwardedFor を true にするのは安全ではありません。最初の値はクライアントが送ったものそのままだからです。代わりに、ホストが上書きするヘッダーを読んでください。

### Ikiform は無料ですか？

いいえ、無料プランはありません。テストは無料で、支払い前でも localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 を購入できます。クレジットに有効期限はありません。

## その他のガイド

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