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

> 本物の訪問者に負担をかけない層でお問い合わせフォームのスパムを止めます。ハニーポット、時間、内容のスコア、レート制限、必要なときだけの人間確認です。

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

手間を増やす前に、安くて目に見えないチェックを重ねます。非表示のハニーポットフィールド、フォーム入力にかかる最低時間、リンクやスパム語句のスコア、訪問者ごとのレート制限です。それでもスパムが通るときだけ Cloudflare Turnstile などの人間確認を追加し、そのトークンはサーバーで検証します。捕まえたものは捨てずに保存しておけば、まれに紛れた本物のメッセージを救い出せます。

公開ページのお問い合わせフォームには、インデックスされてから数日でゴミが届き始めます。そのほとんどはあなたを狙ったものではありません。スクリプトが Web を巡回して `<form>` を見つけ、すべての入力欄を埋めて送信し、どこかにリンクが載ることを期待しているのです。一部はソフトウェアが操作する本物のブラウザーから、さらに一部は同じ売り込み文を何千ものフォームに貼り付けるよう報酬を受けた人から届きます。種類ごとに効くチェックが違うので、1 つの手だけでは解決しません。

このガイドでは、それぞれの防御策が何を捕まえ、本物の訪問者にどんな負担をかけるかを、どのスタックでも動くコードとともに説明します。そのうえで、フォームバックエンドの Ikiform が同じ層をどう代わりに実行するかを紹介します。

## 層は安いものから順に

チェックはコストの順に並べます。リクエストを数えるのはほぼタダなので、ボディを読むより前に実行します。キャプチャのように外部サービスを呼ぶチェックは、安いチェックを通過してから実行します。内容のスコアは解析済みのフィールドが必要なので最後です。Ikiform はすべての送信でこの順番にチェックを実行します。

図: 送信が届いてから保存またはスパムになるまでの、スパムチェックの実行順

1. フォームのエンドポイントに送信が届きます。
2. まずレート制限が訪問者ごとの送信数を数えます。大量送信は、ボディを読む前に 429 で拒否されます。
3. 許可ドメインが、送信元のページと登録したドメインを比較します。
4. 人間確認がオンなら、送信には有効な Turnstile トークンが必要です。
5. ハニーポットが埋められていると、スコアに 5 が加算されます。
6. 時間、リンク、既知のスパム語句、使い捨てアドレス、繰り返しは、より小さな値を加算します。
7. 5 以上なら送信はスパムとして保存されます。それ未満なら受け付けられ、メールと Webhook が送られます。

上の段は送信を拒否します。下の段は拒否せずスコアを付けるだけなので、誤判定があってもクリック 1 つで復元でき、メッセージは失われません。

## 防御策の比較

どの防御策にも代償があります。この表では、それぞれが見逃すものと、声を聞きたい相手に求める負担を正直に示します。

| 防御策 | 止めるもの | 見逃すもの | 本物の訪問者への負担 |
| --- | --- | --- | --- |
| [Honeypot](https://ikiform.com/ja/guides/spam-protection/honeypot.md) | すべての入力欄を埋めるスクリプト | CSS を読むボット、非表示のフィールドを飛ばすボット、人間 | なし（スクリーンリーダーと自動入力から隠していれば） |
| タイムトラップ | 誰も入力できないほど速い送信 | 遅いボットと、待機するボット | なし（最低時間が数秒なら） |
| リンク数、スパム語句、Akismet などの内容のスコア | SEO の売り込み、リンクの貼り逃げ、カジノや薬のスパム | きれいに見えるスパム。リンク入りの本物のメッセージを誤判定することもある | なし（捕まえたものを確認していれば） |
| [Rate limiting](https://ikiform.com/ja/guides/spam-protection/rate-limiting.md) | 大量送信とループするスクリプト | ゆっくり分散したスパム | 1 つの IP を共有するオフィスやイベントは、厳しい制限に引っかかることがある |
| 許可ドメイン | 他の場所からエンドポイントに直接送られる送信 | `Origin` ヘッダーを偽装するスクリプト | なし |
| [Cloudflare Turnstile](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md) | ほとんどの自動化ブラウザー | 報酬を受けた人間のスパマー | たいていは見えない。ときどきクリックが必要で、スクリプトも要る |
| [reCAPTCHA や hCaptcha](https://ikiform.com/ja/guides/spam-protection/recaptcha-alternatives.md) | ほとんどの自動化ブラウザー | 報酬を受けた人間のスパマーと解読サービス | 一部の訪問者に画像パズル、加えてサードパーティのスクリプト |

まずは最初の 5 つから始めましょう。どれも目に見えず、小さなサイトに届くものの大半を捕まえます。人間確認は、まだスパムが通っているとダッシュボードでわかってから追加します。すべての訪問者が負担を感じる唯一の層だからです。[キャプチャなしでスパムを止める](https://ikiform.com/ja/guides/spam-protection/without-captcha.md)ページでは、目に見えない層だけでどこまで防げるかを紹介しています。

## 戦う前にスパムを読む

対策を選ぶ前に、スパムのメッセージを 10 件見てください。共通点から送り主がわかり、それを止める層がわかります。

図: スパムの見た目から、それを止める層へ

1. すべてのフィールドにでたらめな文字が入っているのは、すべての入力欄を埋めるスクリプトです。ハニーポットで捕まえられます。
2. 1 つの送信元から 1 分に何十件も届くのは、ループするスクリプトです。レート制限で止められます。
3. ページの読み込みから 1 秒以内に届く整った送信は、ヘッドレスブラウザーです。タイムトラップか人間確認で止められます。
4. リンク付きの丁寧な売り込みは、フォーム 1 件ごとに報酬を受ける人からです。捕まえられるのは内容のスコアと自分の目での確認だけです。

最後の行はどんなキャプチャでも止められません。人間なら解けるので、内容のスコアと毎週のスパムフォルダーの確認だけが防御策です。

## 層を組み込んだお問い合わせフォーム

ここでは 1 つのお問い合わせフォームを各スタック向けに作ります。どの版にも画面外のハニーポットと人間確認があります。ブラウザーだけの版は、キーが要らない Ikiform のマネージド Turnstile チェックを使います。サーバーの版は自前で組む方法で、サーバーが自分の Cloudflare のキーで Turnstile トークンを検証してから、フィールドを転送します。下でスタックを選んでください。選択はこのガイドのすべてのコードサンプルに引き継がれます。

サーバーのタブでは、小さなヘルパーを 2 つ共有しています。`verifyTurnstile` はトークンが本物かを Cloudflare に問い合わせ、`sendToIkiform` はシークレットキーと訪問者の `Origin` ヘッダーを付けてフィールドを転送します。サーバー側の fetch は自分では `Origin` を送りませんが、Ikiform は無料の `localhost` テストを見分けるのにそれを読みます。どちらも安全側に失敗します。Cloudflare に接続できなければ、黙って通すのではなく訪問者にエラーを表示します。

`lib/spam.ts`:

```ts
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export interface Contact {
  name: string;
  email: string;
  message: string;
  _gotcha: string;
}

export function readContact(data: FormData): Contact {
  return {
    name: String(data.get("name") ?? "").trim(),
    email: String(data.get("email") ?? "").trim(),
    message: String(data.get("message") ?? "").trim(),
    _gotcha: String(data.get("_gotcha") ?? ""),
  };
}

export async function verifyTurnstile(input: { secret: string; token: string; ip?: string | null }) {
  if (!input.token || input.token.length > 2048) return false;
  const body = new URLSearchParams({ secret: input.secret, response: input.token });
  if (input.ip) body.set("remoteip", input.ip);
  try {
    const response = await fetch(SITEVERIFY, { method: "POST", body });
    const result = (await response.json()) as { success?: boolean };
    return result.success === true;
  } catch {
    return false;
  }
}

export async function sendToIkiform(secretKey: string, contact: Contact, origin?: string | null) {
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${secretKey}`,
        ...(origin ? { Origin: origin } : {}),
      },
      body: JSON.stringify(contact),
    });
    const result = await response.json().catch(() => null);
    if (result?.ok) return { ok: true as const };
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const first = Object.values(fields)[0]?.[0];
    return {
      ok: false as const,
      status: response.status,
      message: String(first ?? result?.error?.message ?? "Couldn't send your message. Try again."),
    };
  } catch {
    return { ok: false as const, status: 502, message: "Couldn't reach the form service. Try again." };
  }
}
```

**HTML**

プレーンなフォームにはスクリプトが要りません。Ikiform のフォームの **Spam check** を **Ikiform** に設定すると、トークンのない送信には ikiform.com 上の短い確認ページが表示され、そこで Turnstile を実行してフィールドを転送します。見えないまま通過した人は気づきもしません。

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required maxlength="120" />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required minlength="10" maxlength="5000"></textarea>

  <div class="hp" aria-hidden="true">
    <label for="hp-field">Leave this empty</label>
    <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
  </div>

  <button>Send message</button>
</form>

<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>
```

ハニーポットは `display: none` で隠すのではなく画面外に置きます。そうやって隠されたフィールドを飛ばすボットがいるからです。ラッパーの `aria-hidden` と入力欄の `tabindex` で、スクリーンリーダーとキーボードを近づけないようにします。

**Next.js**

Server Action が自分のキーで Turnstile トークンを検証し、Ikiform のシークレットキーでフィールドを転送します。ハニーポットはそのまま渡すので、埋められていればスパムとして保存され、あとで確認できます。上のヘルパーを `lib/spam.ts` として保存します。`TURNSTILE_SECRET_KEY` と `IKIFORM_SECRET_KEY` をサーバー専用の環境変数として設定し、Ikiform のフォームの **Spam check** は **Off** のままにします。人間確認はすでにサーバーで済んでいるからです。

`app/contact/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { readContact, sendToIkiform, verifyTurnstile } from "@/lib/spam";

export interface ContactState {
  ok: boolean;
  message: string;
  values?: { name: string; email: string; message: string };
}

export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const contact = readContact(formData);
  const values = { name: contact.name, email: contact.email, message: contact.message };
  const incoming = await headers();
  const forwarded = incoming.get("x-forwarded-for");

  const human = await verifyTurnstile({
    secret: process.env.TURNSTILE_SECRET_KEY!,
    token: String(formData.get("cf-turnstile-response") ?? ""),
    ip: forwarded?.split(",")[0]?.trim(),
  });
  if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again.", values };

  const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, contact, incoming.get("origin"));
  if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
  return { ok: false, message: result.message, values };
}
```

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

```tsx
"use client";

import { useActionState } from "react";
import { Turnstile } from "@/components/turnstile";
import { type ContactState, sendMessage } from "./actions";

const initial: ContactState = { ok: false, message: "" };

export function ContactForm() {
  const [state, action, pending] = useActionState(sendMessage, initial);

  if (state.ok) 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.values?.name} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.values?.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>
      <Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} reset={state} />
      {state.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

`components/turnstile.tsx`:

```tsx
"use client";

import { useEffect, useRef } from "react";

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

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

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

function loadTurnstile() {
  return new Promise<TurnstileApi>((resolve) => {
    if (window.turnstile) return resolve(window.turnstile);
    let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
    if (!script) {
      script = document.createElement("script");
      script.src = SCRIPT;
      script.async = true;
      document.head.append(script);
    }
    script.addEventListener("load", () => window.turnstile && resolve(window.turnstile), { once: true });
  });
}

export function Turnstile({ siteKey, reset }: { siteKey: string; reset?: unknown }) {
  const box = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let widgetId: string | undefined;
    let active = true;
    loadTurnstile().then((turnstile) => {
      if (active && box.current) widgetId = turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
    });
    return () => {
      active = false;
      if (widgetId) window.turnstile?.remove(widgetId);
    };
  }, [siteKey, reset]);

  return <div ref={box} />;
}
```

Turnstile のトークンは 1 回しか使えないので、ウィジェットは明示的にレンダリングし、Action が戻るたびに再レンダリングします。React は Action の実行後にフォームをリセットするので、`defaultValue` でエラーの後に訪問者の入力を戻します。

Next.js でさらに詳しく知るには [Turnstile を使った Next.js フォームのスパム対策](https://ikiform.com/ja/guides/spam-protection/nextjs.md) を読んでください。

**Astro**

Astro Action が Zod でフィールドを検証し、`context.clientAddress` を使ってトークンを確認し、送信を転送します。上のヘルパーを `src/lib/spam.ts` として保存し、両方のキーを `astro:env/server` の `getSecret` で読みます。フォームはクライアントの JavaScript なしで送信されるので、試行のたびにページが再読み込みされ、ウィジェットも新しくレンダリングされます。アダプターを追加し、`prerender = false` のままにしてください。`TURNSTILE_SECRET_KEY` と `IKIFORM_SECRET_KEY` をサーバー専用の環境変数として設定し、Ikiform のフォームの **Spam check** は **Off** のままにします。人間確認はすでにサーバーで済んでいるからです。

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
import { sendToIkiform, verifyTurnstile } from "../lib/spam";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1).max(120),
      email: z.email(),
      message: z.string().trim().min(10).max(5000),
      _gotcha: z.string().nullish(),
      "cf-turnstile-response": z.string().nullish(),
    }),
    handler: async (input, context) => {
      const human = await verifyTurnstile({
        secret: getSecret("TURNSTILE_SECRET_KEY") ?? "",
        token: input["cf-turnstile-response"] ?? "",
        ip: context.clientAddress,
      });
      if (!human) throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try the check again." });

      const result = await sendToIkiform(getSecret("IKIFORM_SECRET_KEY") ?? "", {
        name: input.name,
        email: input.email,
        message: input.message,
        _gotcha: input._gotcha ?? "",
      }, context.request.headers.get("origin"));
      if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.message });
      return { sent: true };
    },
  }),
};
```

`src/pages/contact.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

export const prerender = false;

const result = Astro.getActionResult(actions.sendMessage);
const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY;
---

{result?.data?.sent ? (
  <p role="status">Thanks. We'll reply by email.</p>
) : (
  <form method="POST" action={actions.sendMessage}>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div class="cf-turnstile" data-sitekey={siteKey} data-action="contact"></div>
    {result?.error && (
      <p role="alert">{isInputError(result.error) ? "Check your name, email and message." : result.error.message}</p>
    )}
    <button>Send message</button>
  </form>
)}

<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
```

Astro でさらに詳しく知るには [Turnstile を使った Astro フォームのスパム対策](https://ikiform.com/ja/guides/spam-protection/astro.md) を読んでください。

**TanStack Start**

`createServerFn` の POST 関数が `getRequestHeader` で訪問者の IP を読み、トークンを検証してフィールドを転送します。ウィジェットのコンポーネントは Next.js のタブと同じもので、`src/components/turnstile.tsx` に置きます。`TURNSTILE_SECRET_KEY` と `IKIFORM_SECRET_KEY` をサーバー専用の環境変数として設定し、Ikiform のフォームの **Spam check** は **Off** のままにします。人間確認はすでにサーバーで済んでいるからです。

`src/routes/contact.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";

const sendMessage = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return { contact: readContact(data), token: String(data.get("cf-turnstile-response") ?? "") };
  })
  .handler(async ({ data }) => {
    const human = await verifyTurnstile({
      secret: process.env.TURNSTILE_SECRET_KEY!,
      token: data.token,
      ip: getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim(),
    });
    if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again." };
    const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, data.contact, getRequestHeader("origin"));
    return result.ok ? { ok: true, message: "Thanks. We'll reply by email." } : { ok: false, message: result.message };
  });

export const Route = createFileRoute("/contact")({ component: Contact });

function Contact() {
  const send = useServerFn(sendMessage);
  const [state, setState] = useState<{ ok: boolean; message: string } | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setPending(true);
    try {
      setState(await send({ data }));
    } catch {
      setState({ ok: false, message: "Couldn't send your message. Try again." });
    } finally {
      setPending(false);
    }
  }

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

  return (
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <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} />
      <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>
      <Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={state} />
      {state?.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

TanStack Start でさらに詳しく知るには [Turnstile を使った TanStack Start フォームのスパム対策](https://ikiform.com/ja/guides/spam-protection/tanstack-start.md) を読んでください。

**React Router**

ルートの `action` がトークンを検証し、送信を転送します。`actionData` を `reset` として渡すと、応答のたびに新しいウィジェットがレンダリングされます。ウィジェットのコンポーネントは Next.js のタブのものです。`TURNSTILE_SECRET_KEY` と `IKIFORM_SECRET_KEY` をサーバー専用の環境変数として設定し、Ikiform のフォームの **Spam check** は **Off** のままにします。人間確認はすでにサーバーで済んでいるからです。

`app/routes/contact.tsx`:

```tsx
import { data, Form, useNavigation } from "react-router";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";
import type { Route } from "./+types/contact";

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const human = await verifyTurnstile({
    secret: process.env.TURNSTILE_SECRET_KEY!,
    token: String(formData.get("cf-turnstile-response") ?? ""),
    ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
  });
  if (!human) return data({ ok: false, message: "We couldn't confirm you're human. Try the check again." }, { status: 403 });

  const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, readContact(formData), request.headers.get("origin"));
  if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
  return data({ ok: false, message: result.message }, { status: result.status });
}

export default function Contact({ actionData }: Route.ComponentProps) {
  const navigation = useNavigation();
  const pending = navigation.state === "submitting";

  if (actionData?.ok) return <p role="status">{actionData.message}</p>;

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <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} />
      <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>
      <Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={actionData} />
      {actionData?.message ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </Form>
  );
}
```

React Router でさらに詳しく知るには [Turnstile を使った React Router フォームのスパム対策](https://ikiform.com/ja/guides/spam-protection/react-router.md) を読んでください。

**SvelteKit**

デフォルトのフォーム Action が `getClientAddress()` を使ってトークンを検証し、送信を転送します。この版ではあえて `use:enhance` を使いません。試行のたびにページが再読み込みされるので、Turnstile が新しいトークンで新しいウィジェットをレンダリングします。上のヘルパーを `src/lib/spam.ts` として保存します。キーは `src/env.ts` で `defineEnvVars` を使って宣言し、`PUBLIC_TURNSTILE_SITE_KEY` に `public: true` を付けて、ページが `$app/env/public` からインポートできるようにします。`TURNSTILE_SECRET_KEY` と `IKIFORM_SECRET_KEY` をサーバー専用の環境変数として設定し、Ikiform のフォームの **Spam check** は **Off** のままにします。人間確認はすでにサーバーで済んでいるからです。

`src/routes/contact/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY, TURNSTILE_SECRET_KEY } from "$app/env/private";
import { readContact, sendToIkiform, verifyTurnstile } from "#lib/spam.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress, url }) => {
    const data = await request.formData();
    const contact = readContact(data);
    const values = { name: contact.name, email: contact.email, message: contact.message };

    const human = await verifyTurnstile({
      secret: TURNSTILE_SECRET_KEY,
      token: String(data.get("cf-turnstile-response") ?? ""),
      ip: getClientAddress(),
    });
    if (!human) return fail(403, { values, error: "We couldn't confirm you're human. Try the check again." });

    const result = await sendToIkiform(IKIFORM_SECRET_KEY, contact, request.headers.get("origin") ?? url.origin);
    if (!result.ok) return fail(result.status, { values, error: result.message });
    return { sent: true };
  },
} satisfies Actions;
```

`src/routes/contact/+page.svelte`:

```svelte
<script lang="ts">
  import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
</script>

<svelte:head>
  <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</svelte:head>

{#if form?.sent}
  <p role="status">Thanks. We'll reply by email.</p>
{:else}
  <form method="POST">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10" value={form?.values?.message ?? ""}></textarea>
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
    {#if form?.error}
      <p role="alert">{form.error}</p>
    {/if}
    <button>Send message</button>
  </form>
{/if}
```

SvelteKit でさらに詳しく知るには [Turnstile を使った SvelteKit フォームのスパム対策](https://ikiform.com/ja/guides/spam-protection/sveltekit.md) を読んでください。

**Nuxt**

サーバールートがトークンを検証し、送信を転送します。上のヘルパーを `server/utils/spam.ts` として保存すると、Nuxt が自動でインポートします。コンポーネントは Turnstile を明示的にレンダリングし、試行が失敗したらリセットします。`TURNSTILE_SECRET_KEY` と `IKIFORM_SECRET_KEY` をサーバー専用の環境変数として設定し、Ikiform のフォームの **Spam check** は **Off** のままにします。人間確認はすでにサーバーで済んでいるからです。

`server/api/contact.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const body = await readBody<Record<string, string | undefined>>(event);
  const config = useRuntimeConfig(event);

  const human = await verifyTurnstile({
    secret: config.turnstileSecretKey,
    token: body["cf-turnstile-response"] ?? "",
    ip: getRequestIP(event, { xForwardedFor: true }),
  });
  if (!human) throw createError({ status: 403, message: "We couldn't confirm you're human. Try the check again." });

  const result = await sendToIkiform(config.ikiformSecretKey, {
    name: body.name?.trim() ?? "",
    email: body.email?.trim() ?? "",
    message: body.message?.trim() ?? "",
    _gotcha: body._gotcha ?? "",
  }, getRequestHeader(event, "origin"));
  if (!result.ok) throw createError({ status: result.status, message: result.message });
  return { sent: true };
});
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
    turnstileSecretKey: "",
    public: {
      turnstileSiteKey: "",
    },
  },
});
```

`app/components/ContactForm.vue`:

```vue
<script setup lang="ts">
interface TurnstileApi {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): string;
  reset(widgetId: string): void;
}

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
const config = useRuntimeConfig();
const fields = reactive({ name: "", email: "", message: "", _gotcha: "" });
const box = ref<HTMLDivElement>();
const token = ref("");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
let widgetId: string | undefined;

function api() {
  return (window as Window & { turnstile?: TurnstileApi }).turnstile;
}

function loadTurnstile() {
  return new Promise<TurnstileApi>((resolve) => {
    const ready = api();
    if (ready) return resolve(ready);
    let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
    if (!script) {
      script = document.createElement("script");
      script.src = SCRIPT;
      script.async = true;
      document.head.append(script);
    }
    script.addEventListener("load", () => api() && resolve(api()!), { once: true });
  });
}

onMounted(async () => {
  const turnstile = await loadTurnstile();
  if (!box.value) return;
  widgetId = turnstile.render(box.value, {
    sitekey: config.public.turnstileSiteKey,
    action: "contact",
    callback: (value) => (token.value = value),
    "expired-callback": () => (token.value = ""),
  });
});

async function send() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/contact", { method: "POST", body: { ...fields, "cf-turnstile-response": token.value } });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send your message. Try again.";
    status.value = "idle";
    token.value = "";
    if (widgetId) api()?.reset(widgetId);
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="fields.name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" v-model="fields.email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" v-model="fields.message" name="message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" v-model="fields._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="box" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending' || !token">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

Nuxt でさらに詳しく知るには [Turnstile を使った Nuxt フォームのスパム対策](https://ikiform.com/ja/guides/spam-protection/nuxt.md) を読んでください。

**React**

シングルページアプリには Turnstile のシークレットを保管するサーバーがないので、代わりに Ikiform のマネージドチェックを使います。`@ikiform/sdk` の `challenge()` は ikiform.com の小さなフレームを埋め込み、Turnstile がクリックを求めない限り見えないまま、`submit()` 用のトークンを返します。**Spam check** を **Ikiform** に設定し、**Allowed domains** に自分のドメインを追加してください。

`src/ContactForm.tsx`:

```tsx
import { challenge, submit } from "@ikiform/sdk";
import { type FormEvent, useRef, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const check = useRef<HTMLDivElement>(null);
  const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
  const [error, setError] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setStatus("sending");
    setError("");
    try {
      const turnstileToken = await challenge(FORM_ID, { container: check.current ?? undefined });
      const result = await submit(FORM_ID, data, { turnstileToken });
      if (result.ok) {
        setStatus("sent");
        return;
      }
      setError(Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message);
    } catch {
      setError("The human check didn't finish. Try again.");
    }
    setStatus("idle");
  }

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

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <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} />
      <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={check} />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**Vue**

同じマネージドチェックを Vue の単一ファイルコンポーネントで使う例です。フレームは、訪問者がクリックする必要があるときだけ空の `div` の中に表示されます。

`src/components/ContactForm.vue`:

```vue
<script setup lang="ts">
import { challenge, submit } from "@ikiform/sdk";
import { ref } from "vue";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

const check = ref<HTMLDivElement>();
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");

async function send(event: SubmitEvent) {
  const data = new FormData(event.target as HTMLFormElement);
  status.value = "sending";
  error.value = "";
  try {
    const turnstileToken = await challenge(FORM_ID, { container: check.value });
    const result = await submit(FORM_ID, data, { turnstileToken });
    if (result.ok) {
      status.value = "sent";
      return;
    }
    error.value = Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message;
  } catch {
    error.value = "The human check didn't finish. Try again.";
  }
  status.value = "idle";
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="check" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

- どの版も HTML タブの style ブロックにある `.hp` クラスを使います。グローバル CSS に入れてください。
- ハニーポットの名前は、Ikiform のデフォルトである `_gotcha` です。サーバーの版はこれをそのまま転送するので、埋められていれば理由 `honeypot` でスパムフォルダーに入ります。
- Turnstile はフォームに非表示の `cf-turnstile-response` 入力欄を追加します。トークンは 300 秒有効で 1 回しか使えないので、試行ごとに新しいトークンが必要です。
- Cloudflare のテスト用キーを使えば、これらすべてをローカルで動かせます。一覧は [Turnstile のガイド](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md)にあります。

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

下のフィールド定義を試してみてください。Ikiform のバリデーターがブラウザーで動きます。スパムスコアを数える前にサーバーが適用するのと同じルールです。

フォーム: Contact

- **名前** (`name`, text, 必須)
- **メールアドレス** (`email`, email, 必須)
- **トピック** (`topic`, select)
- **メッセージ** (`message`, textarea, 必須)

[お問い合わせフォームのテンプレート](https://ikiform.com/forms/contact-form)と同じフィールドです。定義をフォームの **Fields** タブに貼り付けると、Ikiform は一致しない送信を `422 validation_failed` で拒否します。

## ハニーポット

ハニーポットとは、人には見えず、ボットが埋めてしまう入力欄です。値が入っていれば、その送信はソフトウェアによるものです。訪問者には何の負担もなく、HTML を解析して見つけた入力欄をすべて埋める、最も古くからある一般的なボットを捕まえます。

仕掛けそのものより、うまく隠すことのほうが大切です。雑なハニーポットでは 3 つの問題が起きます:

- **スクリーンリーダーが読み上げてしまう。** 目の見えない訪問者が余分なフィールドを聞いて入力し、スパム扱いされます。`aria-hidden="true"` で包み、空のままにするよう書いたラベルを付けてください。
- **キーボードで止まってしまう。** Tab キーでフォームを移動すると、見えない入力欄で止まります。`tabindex="-1"` で Tab 順から外します。
- **自動入力で埋まってしまう。** ブラウザーやパスワードマネージャーは名前でフィールドを埋めます。`email2` や `phone` と名付けると、本物の人の入力欄が埋められてしまうことがあります。`_gotcha` のように自動入力が認識しない名前を使い、`autocomplete="off"` を付けてください。

[ハニーポットのガイド](https://ikiform.com/ja/guides/spam-protection/honeypot.md)では、隠し方、ボットの見破り方、各スタックでのサーバー側のチェックを解説しています。Ikiform では、ハニーポットが埋められるとスコアに 5 が加算され、それだけでしきい値の 5 に達します。フィールド名はフォームごとに **Honeypot field** で変更できます。

## 時間

人がお問い合わせフォームを埋めるには数秒かかります。多くのボットは数ミリ秒です。タイムトラップはフォームをレンダリングした時刻を記録し、送信時にその差を確認します。素朴な版では、非表示のフィールドにタイムスタンプを入れます:

`タイムトラップ（素朴な版）`:

```ts
const started = Number(formData.get("started"));
const seconds = (Date.now() - started) / 1000;
const tooFast = !Number.isFinite(seconds) || seconds < 3;
```

ボットは好きな数値を送れるので、素のタイムスタンプで止められるのは手を抜いたボットだけです。サーバーでシークレットを使って署名すれば、ボットはレンダリング時刻を遅く偽装できません。署名付きの版は[キャプチャなしでスパムを止める](https://ikiform.com/ja/guides/spam-protection/without-captcha.md#signed-timestamp) ページで作っています。

フォームが SDK や [shadcn/ui コンポーネント](https://ikiform.com/ja/docs/shadcn.md)から作られている場合、Ikiform がレンダリング時刻に署名します。これらは `_ikiform_ts` を送り、レンダリングから 2 秒未満の送信はスコアに 3 が加算されます。偽造されたタイムスタンプや 1 日前のタイムスタンプも 3 が加算されます。プレーンな HTML フォームはこれを送らず、減点もされません。

## 内容のスコア

人が入力したり貼り付けたりするスパムもあり、それはボット向けのチェックをすべて通過します。正体を明かすのは内容です。リンク、営業の語句、使い捨てアドレス、同じ文面の繰り返し。どれか 1 つでブロックするのではなく、シグナルごとに点数を付け、合計が高いものをスパムとして扱います。メッセージにリンクが 1 つあるのは普通ですが、名前のフィールドにリンクがあるのは普通ではありません。

| シグナル | Ikiform の加算 |
| --- | --- |
| ハニーポットが埋められている | +5 |
| 名前や件名のフィールドにリンクがある | +3 |
| 過去 24 時間にこのフォームに同じフィールドが送られた | +3 |
| 署名付きのレンダリング時刻から 2 秒未満で送信された | +3 |
| 全フィールドを合わせてリンクが 3 つ以上 | +2 |
| "seo services" など、既知のスパム語句 22 個のいずれか | +2 |
| 使い捨てメールのドメインのアドレス | +2 |

5 以上なら送信はスパムです。弱いシグナル 1 つだけでは届きません。リンクが 3 つある本物のメッセージは 2 点です。名前のリンクとスパム語句のように 2 つそろうと、たいてい届きます。内容のスコアを自前で付けるなら、昔からあるホスト型の選択肢は Akismet です。多くのチームは上のような正規表現をいくつか書くところから始めます。

## 人間確認

人間確認は、ブラウザーを操作しているのが人であることの証明を求めます。Cloudflare Turnstile のような最近のものはほとんど見えないところで動き、判断に迷うときだけチェックボックスを表示します。ウィジェットはブラウザーにトークンを渡し、サーバーはそのトークンをプロバイダーに送って検証する必要があります。サーバーで検証しないトークンは何も止めません。ボットはウィジェットを飛ばしてトークンなしで送信できるからです。

Ikiform では、各フォームの **Spam check** に 3 つの設定があります:

| 設定 | 何が起きるか | コードの変更 |
| --- | --- | --- |
| **Off** (the default) | チャレンジなし。ハニーポット、時間、スコアは引き続き動く。 | なし |
| **Ikiform** | Ikiform が ikiform.com で Turnstile を実行。Cloudflare のアカウントもキーも不要 | HTML の送信では不要。`fetch` の送信では先に `@ikiform/sdk` の `challenge()` を呼ぶ |
| **Your own Turnstile keys**（自分の Turnstile キー） | 自分のウィジェットとシークレットキー。Ikiform がトークンを検証し、そのホスト名を許可ドメインと照合する | `cf-turnstile-response` を送るよう、フォームにウィジェットをレンダリングする |

トークンのない `fetch` の送信は `403 challenge_required` になります。Cloudflare に接続できない場合、Ikiform は送信を残して理由 `captcha_unavailable` でスパムとして保存するので、あとで復元できます。両方の流れは[スパムのドキュメント](https://ikiform.com/ja/docs/spam.md#human-check)にあります。Turnstile と Google の reCAPTCHA や hCaptcha を比べているなら、[reCAPTCHA の代替](https://ikiform.com/ja/guides/spam-protection/recaptcha-alternatives.md)を参照してください。

## レート制限と許可ドメイン

レート制限は、1 人の訪問者が送信できる頻度に上限を設けます。スパムとメールを見分けるのではなく、量を止めるものです。IP とフォームごとに数え、少しのバーストは許し、`Retry-After` ヘッダー付きの `429` で応答します。[レート制限のガイド](https://ikiform.com/ja/guides/spam-protection/rate-limiting.md)では各スタック向けに実装し、サーバーレスのホストでメモリ上のカウンターが機能しない理由を説明しています。

Ikiform は、ボディを読む前にすべての送信を正確に数えます。各フォームでプリセットを選べます。Standard は訪問者ごとに 1 分 5 件、Relaxed は訪問者ごとに 1 分 20 件、Strict は訪問者ごとに 10 分 2 件です。Standard ではさらに 10 秒に最大 3 件まで、フォーム全体で 1 分 300 件までです。誠実な人が 10 分に 2 回書くことはないので、お問い合わせフォームには Strict が向いています。数値は[レート制限](https://ikiform.com/ja/docs/rate-limits.md)にあります。

**Allowed domains** には、フォームを置くサイトを登録します。それ以外のページからの送信はスパムになるか、**Reject** を選んでいれば `403 origin_not_allowed` で拒否されます。他人が自分のフォームの送信先をあなたのエンドポイントにするのを防げます。本気のスクリプトなら確認対象のヘッダーを偽装できるので、鍵ではなく層の 1 つとして考えてください。

## スパムはどうなるか

捕まったことをボットに知らせてはいけません。エラーは何を変えればよいかを教えてしまいますが、普通の成功ページは何も教えません。そして、届いたスパムをすぐに削除してもいけません。どのフィルターもときどき本物のメッセージを捕まえ、削除したものは戻りません。

スパムと判定された送信にも本物と同じ成功の応答を返すので、ボットは何も学べません。ステータス `spam` で保存され、通知メールも Webhook も送らず、使用量にもカウントされず、30 日後に削除されます。スパムをブロックしたワークスペースには毎週月曜日に、合計、最も多かったフォーム、主な理由をまとめたメールが 1 通届きます。

ダッシュボードのステータスフィルターで **Spam** を選ぶと、それぞれのスコアと理由を確認できます。**Not spam** で元に戻すと、その時点でメールと Webhook が送られます。月曜日のメールは、ワークスペース設定の **Weekly spam summary** でオフにできます。スパムは 30 日間保存されるので、少なくとも月に 1 回は確認してください。

> 同じ本物のメッセージが何度も捕まるなら、その理由を読んでください。`business_name` というフィールドにリンクがあると、名前にリンクがあるとして採点されます。フィールド名を `business` に変えれば、名前のフィールドとして数えられなくなります。

## 状況に合わせて選ぶ

以下の各ページでは、防御策 1 つやサイトの種類 1 つを、そのケースのコード付きでさらに詳しく解説しています。

- [ハニーポットフィールド](https://ikiform.com/ja/guides/spam-protection/honeypot.md): スクリーンリーダー、キーボード、自動入力の邪魔をせずにボットを捕まえるハニーポットを、各スタックのサーバー側チェックとあわせてどんなフォームにも追加します。
- [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 をまったく使わずにフォームスパムの大半を防ぐ方法。ハニーポット、署名付きの表示時刻、内容のスコア、レート制限を単独ではなく合算して使います。
- [reCAPTCHA の代替](https://ikiform.com/ja/guides/spam-protection/recaptcha-alternatives.md): Turnstile や hCaptcha から Proof of Work、CAPTCHA なしまで、フォーム向け reCAPTCHA の代替を比較し、小さく安全な変更で乗り換えます。
- [レート制限](https://ikiform.com/ja/guides/spam-protection/rate-limiting.md): 共有ストアで訪問者ごとにフォーム送信のレート制限をかけ、Retry-After 付きの 429 を返し、1 つの IP を共有するオフィスを締め出さないようにします。
- [静的サイト](https://ikiform.com/ja/guides/spam-protection/static-site.md): シークレットを保持できず送信数も数えられない静的サイトのフォームでも、ハニーポットのパーシャル、安全なスクリプト、サーバー側のチェックでスパムを防ぎます。
- [Webflow フォームのスパム](https://ikiform.com/ja/guides/spam-protection/webflow.md): Webflow 独自のスパム設定が働かない、フォームバックエンドに送信する Webflow フォームを、画面外のハニーポットと人間確認で守ります。
- [Next.js](https://ikiform.com/ja/guides/spam-protection/nextjs.md): Next.js のフォームをスパムから守ります。ハニーポット、サーバーアクションで確認する Turnstile、Vercel・Netlify・Cloudflare での IP ごとの制限、それに合わせた CSP。
- [Astro](https://ikiform.com/ja/guides/spam-protection/astro.md): Action で Astro のフォームをスパムから守ります。ハニーポット、astro:env のシークレットで確認する Turnstile、IP ごとの制限、Astro 組み込みの CSP。
- [TanStack Start](https://ikiform.com/ja/guides/spam-protection/tanstack-start.md): TanStack Start のフォームをスパムから守る方法。クライアント IP 用の関数ミドルウェア、トークンバケット、サーバー関数での Turnstile 検証、CSP。
- [React Router](https://ikiform.com/ja/guides/spam-protection/react-router.md): React Router のフォームをスパムから守る方法。Turnstile 付きのルート Action、cf-connecting-ip をキーにした Workers のレート制限、.server モジュール、CSP ヘッダー。
- [SvelteKit](https://ikiform.com/ja/guides/spam-protection/sveltekit.md): SvelteKit のフォームをスパムから守る方法。getClientAddress を使うフォーム Action、IP ごとの制限、use:enhance の裏の Turnstile、それを許可する CSP。
- [Nuxt](https://ikiform.com/ja/guides/spam-protection/nuxt.md): Nuxt のフォームをスパムから守る方法。Turnstile 付きの Nitro サーバールート、Nitro ストレージでの IP ごとの制限、runtimeConfig のシークレット、routeRules で設定する CSP。

## 動作を確認する

本番で頼る前に、テスト用のフォームで次のことを試してください。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースでも `localhost` からのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 を購入します。クレジットに有効期限はありません。[料金](https://ikiform.com/ja/pricing)を参照してください。

1. フォームの **Allowed domains** に自分のサイトのドメインを、開発中は `localhost` も追加します。空のままにすると、どこからの送信でも受け付けます。
2. ページのフォームに入力して送信します。自分のサンクスページか、Ikiform がホストするサンクスページに移動します。
3. ダッシュボードで **Submissions** を開きます。送ったすべてのフィールドを持つ新しい送信データがあります。
4. ハニーポットを `_gotcha=bot` と埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
5. 名前のフィールドにリンクを、メッセージに語句 `seo services` を入れて送信します。理由 `url_in_name` と `keyword` でスパムとして保存されます。
6. **Spam check** を **Ikiform** にして、プレーンな HTML フォームを送信します。一瞬確認ページが表示され、その後サンクスページに移動します。
7. フォームを続けて何回か送信します。制限を超えるとフォームは `429` を返し、訪問者には Ikiform の「少し待ってください」ページが表示されます。
8. 何も届きませんか？ダッシュボードのステータスフィルターを **Spam** に切り替えてください。スパムにも本物の送信データと同じ成功の応答が返ります。

## トラブルシューティング

| 問題 | 対処 |
| --- | --- |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムは何も送りません。**Spam** フィルターで開いて理由を確認し、スパムではないとマークすればメールと Webhook が送られます。 |
| `403 challenge_required` | フォームのスパムチェックが **Ikiform** に設定されていて、`fetch` の送信にトークンがありませんでした。送信のたびに先に `@ikiform/sdk` の `challenge(formId)` でトークンを取得するか、チェックをオフにしてください。 |
| `403 origin_not_allowed` になる、またはすべての送信がスパムになる | ページのドメインがフォームの **Allowed domains** にありません。追加し、ローカルでのテスト用に `localhost` も追加してください。登録したドメインのサブドメインは許可されます。 |
| `429 rate_limited` | 1 人の訪問者がフォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分 5 件までです。`Retry-After` の時間だけ待つか、共有の 1 つの IP から送信するフォームには Relaxed か Custom を選んでください。 |
| `403 captcha_failed` | Turnstile のトークンがない、期限切れ、または 2 回使われました。トークンの有効期間は 300 秒で 1 回しか使えないので、試行のたびに新しいウィジェットをレンダリングするか、`challenge()` を呼び直してください。 |
| スパムが受け付けられたまま届き続ける | 1 件開いて、他との共通点を読んでください。スクリプトによるスパムなら **Spam check** を **Ikiform** に、人が入力する売り込みならより厳しいレート制限と毎週の確認が必要です。 |
| 本物の訪問者から送信できないと報告がある | ハニーポットがスクリーンリーダーから隠され、自動入力に無視される名前になっているか、また厳しいレート制限が 1 つのオフィス IP の背後にいる人を捕まえていないかを確認してください。 |

## よくある質問

### お問い合わせフォームのスパムを止める一番よい方法は何ですか？

1 つの手ではなく、層を重ねることです。非表示のハニーポット、フォーム入力の最低時間、内容のスコア、レート制限で、本物の訪問者に負担をかけずにほとんどのスパムを止められます。それでもスパムが通るなら Cloudflare Turnstile などの人間確認を追加し、トークンはサーバーで検証します。

### お問い合わせフォームにキャプチャは必要ですか？

最初は要りません。お問い合わせフォームのスパムのほとんどは、ハニーポットとレート制限で止まる単純なボットからです。スパムフォルダーを見てまだボットが通っているとわかったらキャプチャを追加し、画像パズルより Turnstile のような見えないものを選んでください。

### 本物の人からスパムが届くのはなぜですか？

スパムの中には、フォーム 1 件ごとに報酬を受ける人が貼り付けたものがあり、たいていは SEO や開発の売り込みです。ボット向けのチェックでは人を止められません。内容のスコアがリンクや語句で多くを捕まえ、残りはスパムフォルダーで削除します。

### Ikiform は送信者にメッセージがスパム扱いされたことを伝えますか？

いいえ。スパムにも本物のメッセージと同じ成功の応答を返すので、ボットは何も学べません。30 日間保存され、メールも Webhook も送らず、ダッシュボードからスパムではないとマークできます。

### Ikiform で Turnstile を使うのに Cloudflare のアカウントは必要ですか？

いいえ。フォームの Spam check を Ikiform に設定すれば、キーを管理することなく ikiform.com で Turnstile が動きます。自分のページに自分のウィジェットを置きたい場合だけ、自分の Turnstile キーを使ってください。

### ハニーポットはアクセシビリティを損ないますか？

雑に隠すと損ないます。ラッパーに aria-hidden を付けて画面外に隠し、tabindex -1 で Tab 順から外し、ブラウザーが自動入力しない名前を付けてください。

### Ikiform は無料ですか？

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

## その他のガイド

- [ハニーポットフィールド](https://ikiform.com/ja/guides/spam-protection/honeypot.md): スクリーンリーダー、キーボード、自動入力の邪魔をせずにボットを捕まえるハニーポットを、各スタックのサーバー側チェックとあわせてどんなフォームにも追加します。
- [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 をまったく使わずにフォームスパムの大半を防ぐ方法。ハニーポット、署名付きの表示時刻、内容のスコア、レート制限を単独ではなく合算して使います。
- [reCAPTCHA の代替](https://ikiform.com/ja/guides/spam-protection/recaptcha-alternatives.md): Turnstile や hCaptcha から Proof of Work、CAPTCHA なしまで、フォーム向け reCAPTCHA の代替を比較し、小さく安全な変更で乗り換えます。
- [レート制限](https://ikiform.com/ja/guides/spam-protection/rate-limiting.md): 共有ストアで訪問者ごとにフォーム送信のレート制限をかけ、Retry-After 付きの 429 を返し、1 つの IP を共有するオフィスを締め出さないようにします。
- [静的サイト](https://ikiform.com/ja/guides/spam-protection/static-site.md): シークレットを保持できず送信数も数えられない静的サイトのフォームでも、ハニーポットのパーシャル、安全なスクリプト、サーバー側のチェックでスパムを防ぎます。
- [Webflow フォームのスパム](https://ikiform.com/ja/guides/spam-protection/webflow.md): Webflow 独自のスパム設定が働かない、フォームバックエンドに送信する Webflow フォームを、画面外のハニーポットと人間確認で守ります。
- [Next.js](https://ikiform.com/ja/guides/spam-protection/nextjs.md): Next.js のフォームをスパムから守ります。ハニーポット、サーバーアクションで確認する Turnstile、Vercel・Netlify・Cloudflare での IP ごとの制限、それに合わせた CSP。
- [Astro](https://ikiform.com/ja/guides/spam-protection/astro.md): Action で Astro のフォームをスパムから守ります。ハニーポット、astro:env のシークレットで確認する Turnstile、IP ごとの制限、Astro 組み込みの CSP。
- [TanStack Start](https://ikiform.com/ja/guides/spam-protection/tanstack-start.md): TanStack Start のフォームをスパムから守る方法。クライアント IP 用の関数ミドルウェア、トークンバケット、サーバー関数での Turnstile 検証、CSP。
- [React Router](https://ikiform.com/ja/guides/spam-protection/react-router.md): React Router のフォームをスパムから守る方法。Turnstile 付きのルート Action、cf-connecting-ip をキーにした Workers のレート制限、.server モジュール、CSP ヘッダー。
- [SvelteKit](https://ikiform.com/ja/guides/spam-protection/sveltekit.md): SvelteKit のフォームをスパムから守る方法。getClientAddress を使うフォーム Action、IP ごとの制限、use:enhance の裏の Turnstile、それを許可する CSP。
- [Nuxt](https://ikiform.com/ja/guides/spam-protection/nuxt.md): Nuxt のフォームをスパムから守る方法。Turnstile 付きの Nitro サーバールート、Nitro ストレージでの IP ごとの制限、runtimeConfig のシークレット、routeRules で設定する CSP。
- [ビルダーのフォームを卒業する](https://ikiform.com/ja/guides/website-builder-forms.md): Webflow、Netlify、Carrd、Framer のフォームが上限や機能不足に当たったら、デザインしたフォームはそのままに、送信先をフォームバックエンドに変えましょう。
- [ウェブサイトやアプリのフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form.md): どのページにもアプリ内にもフィードバックフォームを設置。評価、コメント、送信元ページ、スパム対策、低評価時の通知まで。
- [リード獲得フォーム](https://ikiform.com/ja/guides/lead-capture.md): 自分のサイトに置くリード獲得フォームです。返信先付きでメールを送り、チームチャットに通知し、CRM に流し込むので、見込み客がまだいるうちに返信できます。
