お問い合わせフォームのスパムを止める3 分で読めます
Turnstile を使った TanStack Start フォームのスパム対策
TanStack Start のフォームをスパムから守る方法。クライアント IP 用の関数ミドルウェア、トークンバケット、サーバー関数での Turnstile 検証、CSP。
クライアント IP とオリジンを 1 回だけ読み、コンテキストとしてすべてのサーバー関数に渡す関数ミドルウェアを追加します。サーバー関数では入力を検証し、IP ごとのバケットからトークンを 1 つ取り、siteverify で Turnstile のトークンを検証してから、ハニーポットと一緒にフィールドを転送します。Start が組み込む CSRF ミドルウェアは残し、src/start.ts を作成する場合は自分で追加し直してください。
TanStack Start ならではの違い
サーバー関数は同一オリジンの RPC エンドポイントです。Start は Sec-Fetch-Site、Origin、Referer を確認する CSRF ミドルウェアで他のサイトから保護しており、別のページが訪問者のブラウザー経由で送信することを防ぎます。ただし、好きなヘッダーを送れるスクリプトは防げません。そのため、スパムのチェックはやはり関数の中に置く必要があります。
これを実現する Start 自身の部品がミドルウェアです。関数ミドルウェアはバリデーターとハンドラーより前に実行され、リクエストヘッダーを読み、context を通じてハンドラーに値を渡せます。IP の取得を 1 回書けば、サイトのすべてのフォームで使えます。
- ブラウザーかスクリプトが、サーバー関数の URL に送信します。
- Start の CSRF ミドルウェアは、自分のオリジンから来たと証明できない送信を拒否します。
- 訪問者ミドルウェアがクライアント IP と Origin ヘッダーを読み、コンテキストとして渡します。
- バリデーターが、ハンドラーに渡る前に入力を正規化します。
- ハンドラーはその IP のバケットからトークンを 1 つ取ります。残っていなければ、後でリトライするよう伝える結果を返します。
- Turnstile のトークンを検証してから、フィールドを Ikiform に転送します。Ikiform が保存し、ハニーポットを評価します。
プロジェクトの準備
src/
routes/
support.tsx
lib/
use-turnstile.ts
server/
bucket.ts
visitor.ts
support.ts
start.ts
.envVITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-forwarded-for- Vite がブラウザーに公開するのは
VITE_で始まる変数だけで、import.meta.envから読みます。シークレットには接頭辞がないので、サーバー上のprocess.envにしか存在しません。 - テスト用のキーは
localhostを含むどのホストでも通ります。環境ごとに本物のキーに差し替えてください。
クライアント IP 用の関数ミドルウェア
import { createMiddleware } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
const HEADER = process.env.CLIENT_IP_HEADER ?? "x-forwarded-for";
export const visitor = createMiddleware({ type: "function" }).server(async ({ next }) => {
const ip = getRequestHeader(HEADER)?.split(",")[0]?.trim() || "unknown";
return next({ context: { ip, origin: getRequestHeader("origin") ?? null } });
});CLIENT_IP_HEADER には、ホストが上書きするヘッダーを設定します。Vercel では x-forwarded-for、Netlify では x-nf-client-connection-ip(Netlify のスタッフが推奨しているものですが、ドキュメントに記載があるのは Functions の context.ip だけです)、Cloudflare では cf-connecting-ip です。nginx の背後にある自前の Node サーバーなら、nginx が設定するヘッダーを使い、nginx を経由せずにアプリへ到達できないようにしてください。このミドルウェアは訪問者の Origin も渡します。サーバー側から Ikiform への fetch はこれを送らず、Ikiform は無料の localhost テストを判定するためにこれを読むからです。
IP ごとのトークンバケット
固定ウィンドウでは、スクリプトが 1 分の終わりに上限いっぱいまで送り、次の 1 分の始めにもう一度送れてしまいます。トークンバケットは 1 回分ずつ回復するので、入力ミスを直す人が待つのは 1 分ではなく数秒です:
const CAPACITY = 3;
const REFILL_MS = 20_000;
const buckets = new Map<string, { tokens: number; at: number }>();
export function take(key: string, now = Date.now()) {
const saved = buckets.get(key) ?? { tokens: CAPACITY, at: now };
const tokens = Math.min(CAPACITY, saved.tokens + (now - saved.at) / REFILL_MS);
if (tokens < 1) {
buckets.set(key, { tokens, at: now });
return { ok: false, retryAfter: Math.ceil(((1 - tokens) * REFILL_MS) / 1000) };
}
if (buckets.size > 50_000) buckets.clear();
buckets.set(key, { tokens: tokens - 1, at: now });
return { ok: true, retryAfter: 0 };
}このカウンターはメモリ上にあり、コンテナで動かす node .output/server/index.mjs のような長時間動く単一サーバーなら正確です。Vercel や Netlify では関数のインスタンスごとに別々の Map を持つので、実際の上限はわかりません。その場合はレート制限のページのようにバケットを Redis に移し、take(key) の形はそのままにします。
サーバー関数
import { createServerFn } from "@tanstack/react-start";
import { take } from "./bucket";
import { visitor } from "./visitor";
const TOPICS = ["billing", "bug", "account", "other"];
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export interface SupportInput {
email: string;
topic: string;
message: string;
_gotcha: string;
token: string;
}
export type SupportResult = { ok: true } | { ok: false; field?: "email" | "message"; message: string };
async function isHuman(token: string, ip: string) {
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 }),
});
const verdict = (await response.json()) as { success: boolean; action?: string };
return verdict.success && verdict.action === "support";
} catch {
return false;
}
}
export const sendSupportRequest = createServerFn({ method: "POST" })
.middleware([visitor])
.validator((input: SupportInput) => ({
email: String(input.email ?? "").trim(),
topic: TOPICS.includes(input.topic) ? input.topic : "other",
message: String(input.message ?? "").trim().slice(0, 5000),
_gotcha: String(input._gotcha ?? ""),
token: String(input.token ?? "").slice(0, 2048),
}))
.handler(async ({ data, context }): Promise<SupportResult> => {
if (!EMAIL.test(data.email)) return { ok: false, field: "email", message: "Enter an address we can reply to." };
if (data.message.length < 10) return { ok: false, field: "message", message: "Tell us a little more." };
const limit = take(context.ip);
if (!limit.ok) return { ok: false, message: `Too many requests. Try again in ${limit.retryAfter} seconds.` };
if (!(await isHuman(data.token, context.ip))) return { ok: false, message: "We couldn't confirm you're human." };
const { token: _token, ...fields } = data;
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}`,
...(context.origin ? { Origin: context.origin } : {}),
},
body: JSON.stringify(fields),
}).catch(() => null);
return response?.ok ? { ok: true } : { ok: false, message: "Your request didn't send. Try again." };
});- バリデーターはハンドラーより前に実行され、入力を正規化するだけです。フィールドエラーは、フォームが該当する入力欄の横に表示できる結果として返ります。
- 安いチェックから先に行います。メールアドレスと長さのチェックはコストゼロ、バケットは Map の参照 1 回、siteverify はネットワーク呼び出し 1 回です。
actionはsupportでなければなりません。同じサイトキーで別のウィジェット用に発行されたトークンは拒否されます。- ハニーポットの
_gotchaはここではチェックせずに転送するので、入力されていればスパムとして保存され、後から確認できます。
IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。ルートとウィジェット
ウィジェットは小さなフックから明示的にレンダリングします。モジュールレベルの Promise によって、クライアント側のナビゲーションでルートが何度マウントされてもスクリプトは 1 回しか読み込まれず、トークンは Turnstile の callback で届きます:
import { useCallback, useEffect, useRef, useState } from "react";
interface Api {
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<Api> | undefined;
function load() {
loading ??= new Promise<Api>((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?: Api }).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(sitekey: string, action: string) {
const ref = useRef<HTMLDivElement>(null);
const api = useRef<Api | null>(null);
const widget = useRef<string | null>(null);
const [token, setToken] = useState("");
useEffect(() => {
let live = true;
load().then((turnstile) => {
if (!live || !ref.current) return;
api.current = turnstile;
widget.current = turnstile.render(ref.current, {
sitekey,
action,
callback: setToken,
"expired-callback": () => setToken(""),
});
});
return () => {
live = false;
if (widget.current) api.current?.remove(widget.current);
widget.current = null;
};
}, [sitekey, action]);
const reset = useCallback(() => {
setToken("");
if (widget.current) api.current?.reset(widget.current);
}, []);
return { ref, token, reset };
}import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { useTurnstile } from "../lib/use-turnstile";
import { type SupportResult, sendSupportRequest } from "../server/support";
export const Route = createFileRoute("/support")({ component: Support });
function Support() {
const send = useServerFn(sendSupportRequest);
const turnstile = useTurnstile(import.meta.env.VITE_TURNSTILE_SITE_KEY, "support");
const [result, setResult] = useState<SupportResult | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
setPending(true);
try {
setResult(
await send({
data: {
email: String(form.get("email") ?? ""),
topic: String(form.get("topic") ?? ""),
message: String(form.get("message") ?? ""),
_gotcha: String(form.get("_gotcha") ?? ""),
token: turnstile.token,
},
}),
);
} catch {
setResult({ ok: false, message: "Couldn't reach the server. Try again." });
} finally {
setPending(false);
turnstile.reset();
}
}
if (result?.ok) return <p role="status">Got it. We'll reply by email within a day.</p>;
const error = result && !result.ok ? result : null;
return (
<form onSubmit={onSubmit} noValidate>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" aria-invalid={error?.field === "email"} />
<label htmlFor="topic">Topic</label>
<select id="topic" name="topic" defaultValue="other">
<option value="billing">Billing</option>
<option value="bug">Something is broken</option>
<option value="account">My account</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">What happened?</label>
<textarea id="message" name="message" rows={6} aria-invalid={error?.field === "message"} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hp" />
<div ref={turnstile.ref} />
{error ? <p role="alert">{error.message}</p> : null}
<button disabled={pending || !turnstile.token}>{pending ? "Sending" : "Send request"}</button>
</form>
);
}- Turnstile がトークンを渡すまでボタンは無効のままなので、空のトークンで送信されることはありません。
turnstile.reset()は毎回の送信後に実行します。トークンは 1 回しか使えず有効期間は 300 秒なので、リトライには新しいトークンが必要です。- このフォームには JavaScript が必要です。サーバー関数は
.url経由で通常のフォーム送信も受けられますが、Turnstile のウィジェットはどのみちブラウザーを必要とします。
src/start.ts のセキュリティヘッダー
Start には独自の CSP オプションがありません。src/start.ts のグローバルなリクエストミドルウェアで、すべてのレスポンスに設定できます。このファイルを作成すると Start がデフォルトで組み込む CSRF ミドルウェアが無効になるので、まずそれを追加し直してください:
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { setResponseHeaders } from "@tanstack/react-start/server";
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'",
"frame-ancestors 'none'",
].join("; ");
const securityHeaders = createMiddleware().server(async ({ next }) => {
setResponseHeaders(new Headers({ "Content-Security-Policy": policy }));
return next();
});
export const startInstance = createStart(() => ({
requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }), securityHeaders],
}));- Start がサーバーレンダリングする HTML にはインラインスクリプトが含まれます。nonce の仕組みがなければ
script-srcに'unsafe-inline'が必要ですが、それでも列挙していないホストからのスクリプトはブロックされます。 - Turnstile は
challenges.cloudflare.comの iframe でウィジェットを描画するので、frame-srcで許可する必要があります。 - CSRF の
filterでチェックをサーバー関数に限定しているので、通常のページ読み込みには影響しません。
デプロイ
| ホスト | CLIENT_IP_HEADER | 制限の仕組み |
|---|---|---|
| 自前の Node サーバーやコンテナ | プロキシが設定するもの | メモリ上で動く |
| Vercel | x-forwarded-for | Redis |
| Netlify | x-nf-client-connection-ip | Redis |
| Cloudflare Workers | cf-connecting-ip | Workers のレート制限バインディング |
Vercel は x-forwarded-for を訪問者のアドレスで上書きすると明記しています。デプロイ前に、本番とプレビューのホスト名をすべて Turnstile ウィジェットに登録してください。
チェックを Ikiform に任せる
Turnstile のキーとリミッターを自分で運用したくなければ、サーバー関数をやめてブラウザーから送信します。onSubmit の send 呼び出しを、確認と送信に置き換えます。box はウィジェットの代わりに置く空の div への ref です:
import { challenge, submit } from "@ikiform/sdk";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const turnstileToken = await challenge(FORM_ID, { container: box.current ?? undefined });
const result = await submit(FORM_ID, new FormData(event.currentTarget), { turnstileToken });フォームの Spam check を Ikiform に設定し、Rate limit で訪問者ごとに 10 分あたり 2 回の Strict を選び、Allowed domains にドメインを追加します。すると Ikiform がハニーポット、ikiform.com 上の Turnstile、IP ごとの制限、内容のスコアリングを行い、捕まえたものを 30 日間保持します。CSP では Cloudflare の代わりに https://ikiform.com を frame-src と connect-src に入れてください。サーバー関数を残す場合のキーについてはサーバー設定ガイドを参照してください。
動作を確認する
- テスト用のキーで開発サーバーを起動し、リクエストを送ります。Submissions に表示されます。プランがなくても、
localhostからのテストは月 50 件まで無料です。 - 続けて 4 回リクエストを送ります。4 回目は秒数付きで、後でリトライするよう伝えるメッセージを返します。
TURNSTILE_SECRET_KEYを常に失敗するシークレットにします。どのリクエストでも人間確認のエラーが表示されます。- ネットワークタブからサーバー関数の呼び出しを
curlでOriginなしに再送します。CSRF ミドルウェアが拒否します。 - ハニーポットを
_gotcha=botと埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
ハマりどころ
| 問題 | 対処 |
|---|---|
src/start.ts を追加したら、クロスサイトの送信がサーバー関数に届くようになった | 独自の src/start.ts はデフォルトの CSRF ミドルウェアを置き換えます。例のように createCsrfMiddleware() を requestMiddleware に追加してください。 |
| すべての訪問者が 1 つのバケットを共有している | CLIENT_IP_HEADER に、ホストが設定しないヘッダーが指定されているため、すべての IP が unknown になっています。上の表のヘッダーを使ってください。 |
| Vercel で制限がランダムにリセットされるように見える | 関数のインスタンスごとにメモリが別々です。バケットを Redis に移してください。 |
| 開発環境でウィジェットが 2 回描画される | React の Strict Mode はエフェクトを 2 回マウントします。クリーンアップで最初のウィジェットが削除されるので、本番では 1 つだけ表示されます。 |
よくある質問
@tanstack/react-start/server の getRequestHeader で、ホストが設定するヘッダーを読みます。関数ミドルウェアで 1 回だけ読み、コンテキストとしてハンドラーに渡すのが理想です。Vercel では x-forwarded-for、Netlify では x-nf-client-connection-ip、Cloudflare では cf-connecting-ip を使います。
はい。src/start.ts を定義しない限り、Start はサーバー関数用の CSRF ミドルウェアを組み込みます。定義した場合は createCsrfMiddleware を自分で追加します。これが防ぐのはクロスサイトのブラウザーからの送信で、スクリプトは防げないので、スパムのチェックはやはり必要です。
長時間動く単一の Node サーバーなら可能です。サーバーレスのホストではインスタンスごとにメモリが別々なので、Redis や Cloudflare のレート制限バインディングなどの共有ストアを使ってください。
いいえ、無料プランはありません。テストは無料で、支払い前でも localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 を購入できます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com