# Turnstile を使った Next.js フォームのスパム対策

> Next.js のフォームをスパムから守ります。ハニーポット、サーバーアクションで確認する Turnstile、Vercel・Netlify・Cloudflare での IP ごとの制限、それに合わせた CSP。

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

Next.js では、スパムのチェックをサーバーアクションの中で行います。アクションはフォームなしで誰でも呼べる公開の POST エンドポイントだからです。ホストが設定するクライアント IP でレート制限をかけ、siteverify で Turnstile のトークンを検証してから、ハニーポットをそのまま残してフィールドを転送します。Turnstile のスクリプトは CSP の nonce を付けて `next/script` で読み込み、`frame-src` で `challenges.cloudflare.com` を許可します。

## Next.js ならではの違い

サーバーアクションは関数呼び出しのように見えますが、Next.js はそれを、ページの JavaScript に ID が含まれる POST エンドポイントに変換します。ボットにフォームは要りません。好きなフィールドで、好きなだけ速く、その POST を再送できます。だからチェックはすべてアクションの中に置き、コンポーネントだけに置いてはいけません。

このページの残りは、Next.js の 3 つの事情を前提にしています:

- **`request.ip` はありません。** クライアント IP はヘッダーから得られ、どのヘッダーを信頼できるかはホストによって異なります。
- **nonce を使うと動的レンダリングになります。** リクエストごとの nonce は、ビルド時に生成されたページには適用できません。
- **クライアントナビゲーションではページが生き続けます。** 1 回だけ読み込んだ Turnstile のスクリプトは、ソフトナビゲーションの後にウィジェットを再描画しません。

図: Next.js のサーバーアクション内でチェックが実行される順番

1. フォーム、またはそれを再送するスクリプトが、サーバーアクションに送信します。
2. アクションは、訪問者の送信数を Vercel Firewall のルールと照合します。上限を超えていれば、エラー状態を返して終了します。
3. Turnstile のトークンとクライアント IP を siteverify に送り、応答のアクション名とホスト名を確認します。
4. そのあとで初めて、ハニーポットも含めたフィールドを Ikiform に転送し、Ikiform がメッセージをスコア判定して保存します。

最もコストの低いチェックを最初に実行します。ハニーポットはそのまま渡されるので、埋められていれば黙って捨てられるのではなく、スパムとして保存されます。

## プロジェクトの準備

このページで App Router のプロジェクトに追加するファイル:

`ファイル`:

```text
app/
  contact/
    page.tsx
    contact-form.tsx
    actions.ts
lib/
  client-ip.ts
  turnstile.ts
proxy.ts
.env.local
```

Cloudflare のダッシュボードで Turnstile のウィジェットを作成し、フォームが動くすべてのホスト名を登録します。開発中は、`localhost` を含むどのホストでも通る Cloudflare のテスト用キーを使います:

`.env.local`:

```bash
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
TURNSTILE_HOSTNAME=
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-real-ip
```

- ブラウザーに届くのは `NEXT_PUBLIC_TURNSTILE_SITE_KEY` だけです。Next.js は `NEXT_PUBLIC_` の値をビルド時に埋め込むので、サイトキーを変えるには再ビルドが必要です。
- テスト用トークンにはホスト名が含まれないので、ローカルでは `TURNSTILE_HOSTNAME` を空のままにします。本番では実際のドメインを設定してください。
- シークレットを `2x0000000000000000000000000000000AA` に替えると、すべてのチェックが失敗する様子を確認できます。

## ホストが設定するクライアント IP を読む

訪問者が書き換えられるヘッダーをキーにしたレート制限は、制限になりません。各ホストは受信時に上書きするヘッダーを設定しており、信頼できるのはそのヘッダーだけです:

図: 各ホストで本当のクライアント IP を運ぶヘッダー

1. clientIp() は CLIENT_IP_HEADER で指定した 1 つのヘッダーだけを読み、残りは無視します。
2. Vercel は x-forwarded-for と x-real-ip を訪問者のアドレスで上書きします。
3. Netlify はアドレスを x-nf-client-connection-ip に入れます。
4. Cloudflare は、OpenNext でデプロイした Next.js も含め、cf-connecting-ip を設定します。

Vercel は、なりすましを防ぐために X-Forwarded-For を上書きすると明記しています。Netlify のドキュメントが挙げているのは Functions の context.ip だけで、x-nf-client-connection-ip は Netlify のスタッフが案内しているヘッダーです。

`lib/client-ip.ts`:

```ts
import "server-only";
import { headers } from "next/headers";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-real-ip";

export async function clientIp() {
  const value = (await headers()).get(HEADER);
  return value?.split(",")[0]?.trim() || null;
}
```

## サーバー専用のヘルパーで Turnstile を検証する

`lib/turnstile.ts`:

```ts
import "server-only";

interface Verdict {
  success: boolean;
  hostname?: string;
  action?: string;
  "error-codes"?: string[];
}

export type Check = { ok: true } | { ok: false; reason: string };

export async function verifyTurnstile(token: string, ip: string | null): Promise<Check> {
  if (!token || token.length > 2048) return { ok: false, reason: "missing-input-response" };

  let verdict: Verdict;
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token, remoteip: ip ?? undefined }),
      signal: AbortSignal.timeout(5000),
    });
    verdict = (await response.json()) as Verdict;
  } catch {
    return { ok: false, reason: "siteverify-unreachable" };
  }

  if (!verdict.success) return { ok: false, reason: verdict["error-codes"]?.join(",") || "failed" };
  if (verdict.action !== "contact") return { ok: false, reason: "wrong-action" };
  const expected = process.env.TURNSTILE_HOSTNAME;
  if (expected && verdict.hostname !== expected) return { ok: false, reason: "wrong-hostname" };
  return { ok: true };
}
```

- トークンの有効期間は 300 秒で、検証できるのは 1 回だけです。その後は siteverify が `timeout-or-duplicate` を返します。
- `success` だけでは不十分です。あなたのサイトキーを使って別のサイトで発行されたトークンも `success` になるので、ヘルパーは `action` と、本番では `hostname` も確認します。
- 失敗時は安全側に倒します。Cloudflare に到達できなければ、訪問者にはエラーが表示され、ボットは 1 つも通りません。

## サーバーアクション

`app/contact/actions.ts`:

```ts
"use server";

import { checkRateLimit } from "@vercel/firewall";
import { headers } from "next/headers";
import { clientIp } from "@/lib/client-ip";
import { verifyTurnstile } from "@/lib/turnstile";

export interface ContactState {
  status: "idle" | "sent" | "error";
  message: string;
  fields?: { name: string; email: string; message: string };
}

export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const fields = {
    name: String(formData.get("name") ?? "").trim(),
    email: String(formData.get("email") ?? "").trim(),
    message: String(formData.get("message") ?? "").trim(),
  };

  if (process.env.VERCEL) {
    const { rateLimited } = await checkRateLimit("contact-form", { headers: await headers() });
    if (rateLimited) return { status: "error", message: "Too many messages from your connection. Try again in a minute.", fields };
  }

  const check = await verifyTurnstile(String(formData.get("cf-turnstile-response") ?? ""), await clientIp());
  if (!check.ok) {
    console.warn("turnstile", check.reason);
    return { status: "error", message: "We couldn't confirm you're human. Try the check again.", fields };
  }

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      Origin: (await headers()).get("origin") ?? "",
    },
    body: JSON.stringify({ ...fields, _gotcha: String(formData.get("_gotcha") ?? "") }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { status: "sent", message: "Thanks. We'll reply by email." };
  return { status: "error", message: result?.error?.message ?? "Your message didn't send. Try again.", fields };
}
```

Vercel では、制限は Firewall で行います。プロジェクトの **Firewall** を開き、条件が `@vercel/firewall` のルールを追加して、**Rate limit ID** を `contact-form` にし、1 分あたり 5 リクエストなどのウィンドウを選びます。`checkRateLimit` はリクエストヘッダーを受け取るので、`Request` オブジェクトのないサーバーアクションの中でも動き、`rateLimitKey` を渡さなければクライアント IP ごとにカウントします。

- Vercel はルールをリージョンごとにカウントするので、複数のリージョンに分散したトラフィックは設定した数を超えることがあります。お問い合わせフォームなら問題ありません。全体で正確にカウントしたいなら、[レート制限のページ](https://ikiform.com/ja/guides/spam-protection/rate-limiting.md)のように Redis を使ってください。
- サーバー側の fetch は `Origin` ヘッダーを送らないので、訪問者のものを引き継いで渡します。Ikiform はこれを読んで無料の `localhost` テストを判別します。
- Vercel 以外では Firewall の呼び出しはスキップされます。そこに `clientIp()` をキーにした Redis や Workers のリミッターを置いてください。

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

## フォームとウィジェット

`app/contact/contact-form.tsx`:

```tsx
"use client";

import Script from "next/script";
import { useActionState, useEffect, useRef } from "react";
import { type ContactState, sendMessage } from "./actions";

interface Turnstile {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: Turnstile;
  }
}

const initial: ContactState = { status: "idle", message: "" };

export function ContactForm({ nonce }: { nonce?: string }) {
  const [state, action, pending] = useActionState(sendMessage, initial);
  const box = useRef<HTMLDivElement>(null);
  const widget = useRef<string | null>(null);

  function mount() {
    if (!box.current || !window.turnstile || widget.current) return;
    widget.current = window.turnstile.render(box.current, {
      sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!,
      action: "contact",
    });
  }

  useEffect(() => {
    if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
  }, [state]);

  useEffect(
    () => () => {
      if (widget.current) window.turnstile?.remove(widget.current);
      widget.current = null;
    },
    [],
  );

  if (state.status === "sent") return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} defaultValue={state.fields?.name} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.fields?.email} />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.fields?.message} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <div ref={box} />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
      <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" nonce={nonce} onReady={mount} />
    </form>
  );
}
```

- `?render=explicit` を付けると、スクリプトがページ内のウィジェットを探さなくなります。`onReady` はスクリプトの読み込み時と、コンポーネントがマウントされるたびに実行されるので、クライアント側のナビゲーションにも対応できます。
- トークンは 1 回しか使えません。アクションがエラーを返したら、エフェクトがウィジェットをリセットし、次の試行で新しいトークンを送れるようにします。
- `action` がサーバーアクションそのものなので、フォームはハイドレーション前でも送信されます。JavaScript がなければウィジェットもないので、siteverify は送信を拒否します。人間確認にはブラウザーが必要です。
- [ハニーポットのガイド](https://ikiform.com/ja/guides/spam-protection/honeypot.md)の `.hp` クラスで、ハニーポットを画面外に隠します。

`app/contact/page.tsx`:

```tsx
import { headers } from "next/headers";
import { ContactForm } from "./contact-form";

export default async function Page() {
  const nonce = (await headers()).get("x-nonce") ?? undefined;

  return (
    <main>
      <h1>Contact us</h1>
      <ContactForm nonce={nonce} />
    </main>
  );
}
```

## Turnstile を動かせる CSP

Next.js は `Content-Security-Policy` リクエストヘッダーから nonce を読み、自分のスクリプトと、`nonce` プロパティを渡した `<Script>` に付けます。Cloudflare によると、Turnstile は読み込むものに nonce を引き継ぎ、その iframe には `frame-src https://challenges.cloudflare.com` が必要です。Next.js 16 では、`middleware.ts` に代わってファイル名が `proxy.ts` になっています:

`proxy.ts`:

```ts
import { type NextRequest, NextResponse } from "next/server";

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const dev = process.env.NODE_ENV === "development";
  const policy = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://challenges.cloudflare.com${dev ? " 'unsafe-eval'" : ""}`,
    "frame-src https://challenges.cloudflare.com",
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
  ].join("; ");

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", policy);
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", policy);
  return response;
}

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};
```

- `'strict-dynamic'` があると、最新のブラウザーは nonce を信頼してホストを無視します。ホストは古いブラウザーのために残しておきます。
- ページで `headers()` を読むとリクエストごとのレンダリングになり、これは nonce に必須です。静的にビルドしたお問い合わせページには nonce を付けられないので、代わりに `next.config` の `headers()` で nonce なしのポリシーを使います。その代償として `'unsafe-inline'` が必要になります。

## デプロイ

| ホスト | CLIENT_IP_HEADER | レート制限 | 注意点 |
| --- | --- | --- | --- |
| Vercel | `x-real-ip` | Firewall のルールと `checkRateLimit` | テストする環境ごとにルールを追加する |
| Netlify | `x-nf-client-connection-ip` | Upstash などの Redis | サーバーアクションは 1 つの Netlify 関数で動く |
| Cloudflare（OpenNext 使用） | `cf-connecting-ip` | Workers のレート制限バインディング | バインディングは概算で、ロケーションごとにカウントされる |

本番では `TURNSTILE_HOSTNAME` を実際のドメインに設定します。プレビューのデプロイには独自のホスト名が付くので、ウィジェットに追加するか、プレビューではチェックをオフにしてください。

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

ここまでの仕組みが必要なのは、サーバーアクションがシークレットキーで転送し、それによって Ikiform 自身の人間確認と訪問者ごとの制限がオフになるからです。代わりにブラウザーから送信すれば、Ikiform がハニーポット、Turnstile、レート制限を実行してくれます。Cloudflare のキーは不要で、`proxy.ts` の変更も `frame-src` と `connect-src` で `https://ikiform.com` を許可するだけ、アクションも要りません:

`app/contact/contact-form.tsx`:

```tsx
"use client";

import { challenge, submit } from "@ikiform/sdk";
import { useActionState, useRef } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const frame = useRef<HTMLDivElement>(null);
  const [message, action, pending] = useActionState(async (_previous: string, data: FormData) => {
    try {
      const turnstileToken = await challenge(FORM_ID, { container: frame.current ?? undefined });
      const result = await submit(FORM_ID, data, { turnstileToken });
      return result.ok ? "sent" : result.error.message;
    } catch {
      return "The human check didn't finish. Try again.";
    }
  }, "");

  if (message === "sent") return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <form action={action}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <div ref={frame} />
      {message ? <p role="alert">{message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

フォームの **Spam check** を **Ikiform** に、**Rate limit** を Strict（訪問者ごとに 10 分あたり 2 件）にし、**Allowed domains** に自分のドメインと `localhost` を追加します。`challenge()` は ikiform.com の小さなフレームをマウントし、Turnstile がクリックを求めない限り見えないままです。スパムには本物のメッセージと同じ成功の応答が返り、捕まった理由とともに保存され、30 日間保持された後に削除されます。フォームとキーの作成は [Next.js のセットアップガイド](https://ikiform.com/ja/docs/guides/nextjs.md)で説明しています。

## 動作を確認する

1. テスト用キーで `next dev` を実行し、メッセージを送ります。**Submissions** に届きます。プランがなくても、ワークスペースは `localhost` からのテスト送信を月 50 件まで受け付けます。
2. `TURNSTILE_SECRET_KEY` を常に失敗するシークレットにして送信します。フォームには人間確認のエラーが表示され、入力したテキストは残ります。
3. Firewall のルールを設定した Vercel のプレビューで、1 分間に 6 回送信します。6 回目に制限のメッセージが表示されます。
4. ハニーポットを `_gotcha=bot` と埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
5. コンソールを表示した状態でページを開きます。`challenges.cloudflare.com` に関する CSP 違反は出ません。

## ハマりどころ

| 問題 | 対処 |
| --- | --- |
| ページに移動してもウィジェットが表示されない | スクリプトが前のページで読み込まれていて、再びページを探索しません。サンプルのように `onLoad` ではなく `onReady` から描画してください。 |
| 本番ですべてのメッセージが人間確認に失敗する | `TURNSTILE_HOSTNAME` が siteverify の返すホスト名と一致していません。`www.` の有無で食い違うことがよくあります。`check.reason` をログに出して確認してください。 |
| すべての訪問者が 1 つのレート制限のバケットを共有してしまう | `CLIENT_IP_HEADER` がホストの設定しないヘッダーを指しているので、`clientIp()` が null を返しています。上の表からヘッダーを選んでください。 |
| `checkRateLimit` が `error: "not-found"` を返す | この環境には Rate limit ID が `contact-form` の Firewall ルールがありません。追加してから、Firewall の変更を公開してください。 |

## よくある質問

### Next.js のサーバーアクションに Cloudflare Turnstile を追加するには？

クライアントコンポーネントでウィジェットを描画し、next/script でスクリプトを読み込み、サーバーアクションで cf-turnstile-response フィールドを読みます。それをシークレットキーとともに siteverify に送り、応答が success で、アクションとホスト名が想定どおりの場合にだけ処理を続けます。

### Next.js のサーバーアクションでクライアント IP を取得するには？

next/headers の headers() で、ホストが設定するヘッダーから読みます。Vercel なら x-real-ip か x-forwarded-for、Netlify なら x-nf-client-connection-ip、Cloudflare なら cf-connecting-ip です。App Router の Next.js には request.ip がありません。

### CSP の nonce を使うと、お問い合わせページは静的でなくなりますか？

はい。nonce はリクエストごとに作られるので、ページはリクエストごとにレンダリングする必要があります。お問い合わせページを動的にしておくか、challenges.cloudflare.com をホストで許可する nonce なしのポリシーを使ってください。

### 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 の代替を比較し、小さく安全な変更で乗り換えます。
