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

> TanStack Start のフォームをスパムから守る方法。クライアント IP 用の関数ミドルウェア、トークンバケット、サーバー関数での Turnstile 検証、CSP。

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

クライアント 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 回書けば、サイトのすべてのフォームで使えます。

図: TanStack Start のサーバー関数の前に並ぶミドルウェアの連鎖

1. ブラウザーかスクリプトが、サーバー関数の URL に送信します。
2. Start の CSRF ミドルウェアは、自分のオリジンから来たと証明できない送信を拒否します。
3. 訪問者ミドルウェアがクライアント IP と Origin ヘッダーを読み、コンテキストとして渡します。
4. バリデーターが、ハンドラーに渡る前に入力を正規化します。
5. ハンドラーはその IP のバケットからトークンを 1 つ取ります。残っていなければ、後でリトライするよう伝える結果を返します。
6. Turnstile のトークンを検証してから、フィールドを Ikiform に転送します。Ikiform が保存し、ハニーポットを評価します。

各層は次の層よりコストが低く、拒否された送信が siteverify や Ikiform に届くことはありません。

## プロジェクトの準備

`ファイル`:

```text
src/
  routes/
    support.tsx
  lib/
    use-turnstile.ts
  server/
    bucket.ts
    visitor.ts
    support.ts
  start.ts
.env
```

`.env`:

```bash
VITE_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 用の関数ミドルウェア

`src/server/visitor.ts`:

```ts
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 分ではなく数秒です:

`src/server/bucket.ts`:

```ts
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 を持つので、実際の上限はわかりません。その場合は[レート制限のページ](https://ikiform.com/ja/guides/spam-protection/rate-limiting.md)のようにバケットを Redis に移し、`take(key)` の形はそのままにします。

## サーバー関数

`src/server/support.ts`:

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

## ルートとウィジェット

ウィジェットは小さなフックから明示的にレンダリングします。モジュールレベルの Promise によって、クライアント側のナビゲーションでルートが何度マウントされてもスクリプトは 1 回しか読み込まれず、トークンは Turnstile の `callback` で届きます:

`src/lib/use-turnstile.ts`:

```ts
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 };
}
```

`src/routes/support.tsx`:

```tsx
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 ミドルウェアが無効になるので、まずそれを追加し直してください:

`src/start.ts`:

```ts
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 です:

`src/routes/support.tsx`:

```ts
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` に入れてください。サーバー関数を残す場合のキーについては[サーバー設定ガイド](https://ikiform.com/ja/docs/guides/server.md)を参照してください。

## 動作を確認する

1. テスト用のキーで開発サーバーを起動し、リクエストを送ります。**Submissions** に表示されます。プランがなくても、`localhost` からのテストは月 50 件まで無料です。
2. 続けて 4 回リクエストを送ります。4 回目は秒数付きで、後でリトライするよう伝えるメッセージを返します。
3. `TURNSTILE_SECRET_KEY` を常に失敗するシークレットにします。どのリクエストでも人間確認のエラーが表示されます。
4. ネットワークタブからサーバー関数の呼び出しを `curl` で `Origin` なしに再送します。CSRF ミドルウェアが拒否します。
5. ハニーポットを `_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 Start のサーバー関数でクライアント IP を取得するには？

@tanstack/react-start/server の getRequestHeader で、ホストが設定するヘッダーを読みます。関数ミドルウェアで 1 回だけ読み、コンテキストとしてハンドラーに渡すのが理想です。Vercel では x-forwarded-for、Netlify では x-nf-client-connection-ip、Cloudflare では cf-connecting-ip を使います。

### TanStack Start のサーバー関数に CSRF 対策はありますか？

はい。src/start.ts を定義しない限り、Start はサーバー関数用の CSRF ミドルウェアを組み込みます。定義した場合は createCsrfMiddleware を自分で追加します。これが防ぐのはクロスサイトのブラウザーからの送信で、スクリプトは防げないので、スパムのチェックはやはり必要です。

### TanStack Start のレート制限をメモリ上で行えますか？

長時間動く単一の Node サーバーなら可能です。サーバーレスのホストではインスタンスごとにメモリが別々なので、Redis や Cloudflare のレート制限バインディングなどの共有ストアを使ってください。

### Ikiform は無料ですか？

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

## その他のガイド

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