お問い合わせフォームのスパムを止める4 分で読めます
Turnstile を使った Next.js フォームのスパム対策
Next.js のフォームをスパムから守ります。ハニーポット、サーバーアクションで確認する Turnstile、Vercel・Netlify・Cloudflare での IP ごとの制限、それに合わせた CSP。
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 のスクリプトは、ソフトナビゲーションの後にウィジェットを再描画しません。
- フォーム、またはそれを再送するスクリプトが、サーバーアクションに送信します。
- アクションは、訪問者の送信数を Vercel Firewall のルールと照合します。上限を超えていれば、エラー状態を返して終了します。
- Turnstile のトークンとクライアント IP を siteverify に送り、応答のアクション名とホスト名を確認します。
- そのあとで初めて、ハニーポットも含めたフィールドを Ikiform に転送し、Ikiform がメッセージをスコア判定して保存します。
プロジェクトの準備
このページで App Router のプロジェクトに追加するファイル:
app/
contact/
page.tsx
contact-form.tsx
actions.ts
lib/
client-ip.ts
turnstile.ts
proxy.ts
.env.localCloudflare のダッシュボードで Turnstile のウィジェットを作成し、フォームが動くすべてのホスト名を登録します。開発中は、localhost を含むどのホストでも通る Cloudflare のテスト用キーを使います:
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 を読む
訪問者が書き換えられるヘッダーをキーにしたレート制限は、制限になりません。各ホストは受信時に上書きするヘッダーを設定しており、信頼できるのはそのヘッダーだけです:
- clientIp() は CLIENT_IP_HEADER で指定した 1 つのヘッダーだけを読み、残りは無視します。
- Vercel は x-forwarded-for と x-real-ip を訪問者のアドレスで上書きします。
- Netlify はアドレスを x-nf-client-connection-ip に入れます。
- Cloudflare は、OpenNext でデプロイした Next.js も含め、cf-connecting-ip を設定します。
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 を検証する
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 つも通りません。
サーバーアクション
"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_SECRET_KEY はサーバー専用の環境変数に置いてください。フォームとウィジェット
"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クラスで、ハニーポットを画面外に隠します。
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 になっています:
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 を許可するだけ、アクションも要りません:
"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 のセットアップガイドで説明しています。
動作を確認する
- テスト用キーで
next devを実行し、メッセージを送ります。Submissions に届きます。プランがなくても、ワークスペースはlocalhostからのテスト送信を月 50 件まで受け付けます。 TURNSTILE_SECRET_KEYを常に失敗するシークレットにして送信します。フォームには人間確認のエラーが表示され、入力したテキストは残ります。- Firewall のルールを設定した Vercel のプレビューで、1 分間に 6 回送信します。6 回目に制限のメッセージが表示されます。
- ハニーポットを
_gotcha=botと埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 - コンソールを表示した状態でページを開きます。
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/script でスクリプトを読み込み、サーバーアクションで cf-turnstile-response フィールドを読みます。それをシークレットキーとともに siteverify に送り、応答が success で、アクションとホスト名が想定どおりの場合にだけ処理を続けます。
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 がありません。
はい。nonce はリクエストごとに作られるので、ページはリクエストごとにレンダリングする必要があります。お問い合わせページを動的にしておくか、challenges.cloudflare.com をホストで許可する nonce なしのポリシーを使ってください。
いいえ、無料プランはありません。テストは無料で、支払い前でも localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 を購入できます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com