お問い合わせフォームのスパムを止める

お問い合わせフォームのスパムを止める4 分で読めます

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

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

Preet SutharIkiform 創業者

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 のサーバーアクション内でチェックが実行される順番サーバーアクション誰でも送れるPOSTレート制限VercelFirewallのルール、IPごとsiteverifyトークン、アクション、ホスト名Ikiformハニーポットを判定、保存エラー状態何も転送しない上限以内人間上限超過Next.js のサーバーアクション内でチェックが実行される順番サーバーアクション誰でも送れる POSTレート制限Vercel Firewallのルール、IP ごとsiteverifyトークン、アクション、ホスト名Ikiformハニーポットを判定、保存エラー状態何も転送しない上限以内人間上限超過
  1. フォーム、またはそれを再送するスクリプトが、サーバーアクションに送信します。
  2. アクションは、訪問者の送信数を Vercel Firewall のルールと照合します。上限を超えていれば、エラー状態を返して終了します。
  3. Turnstile のトークンとクライアント IP を siteverify に送り、応答のアクション名とホスト名を確認します。
  4. そのあとで初めて、ハニーポットも含めたフィールドを Ikiform に転送し、Ikiform がメッセージをスコア判定して保存します。
最もコストの低いチェックを最初に実行します。ハニーポットはそのまま渡されるので、埋められていれば黙って捨てられるのではなく、スパムとして保存されます。

プロジェクトの準備

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

ファイル
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
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 を運ぶヘッダー受信リクエストclientIp()CLIENT_IP_HEADER を読むVercelx-real-ip かx-forwarded-forNetlifyx-nf-client-connection-ipCloudflarecf-connecting-ip各ホストで本当のクライアント IP を運ぶヘッダー受信リクエストclientIp()CLIENT_IP_HEADERを読むVercelx-real-ip かx-forwarded-forNetlifyx-nf-client-connection-ipCloudflarecf-connecting-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
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
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
"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 はルールをリージョンごとにカウントするので、複数のリージョンに分散したトラフィックは設定した数を超えることがあります。お問い合わせフォームなら問題ありません。全体で正確にカウントしたいなら、レート制限のページのように Redis を使ってください。
  • サーバー側の fetch は Origin ヘッダーを送らないので、訪問者のものを引き継いで渡します。Ikiform はこれを読んで無料の localhost テストを判別します。
  • Vercel 以外では Firewall の呼び出しはスキップされます。そこに clientIp() をキーにした Redis や Workers のリミッターを置いてください。
サーバー側の版はシークレットキーで送信するので、Ikiform は Allowed domains と自身の人間確認をスキップします。すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分 5 件という Standard の制限は全訪問者の合計で数えられます。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

フォームとウィジェット

app/contact/contact-form.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 は送信を拒否します。人間確認にはブラウザーが必要です。
  • ハニーポットのガイドの .hp クラスで、ハニーポットを画面外に隠します。
app/contact/page.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
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レート制限注意点
Vercelx-real-ipFirewall のルールと checkRateLimitテストする環境ごとにルールを追加する
Netlifyx-nf-client-connection-ipUpstash などの Redisサーバーアクションは 1 つの Netlify 関数で動く
Cloudflare(OpenNext 使用)cf-connecting-ipWorkers のレート制限バインディングバインディングは概算で、ロケーションごとにカウントされる

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

チェックを Ikiform に任せる

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

app/contact/contact-form.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 のセットアップガイドで説明しています。

動作を確認する

  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 の変更を公開してください。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com