ガイド4 分で読めます
お問い合わせフォームのスパムを止める方法
本物の訪問者に負担をかけない層でお問い合わせフォームのスパムを止めます。ハニーポット、時間、内容のスコア、レート制限、必要なときだけの人間確認です。
手間を増やす前に、安くて目に見えないチェックを重ねます。非表示のハニーポットフィールド、フォーム入力にかかる最低時間、リンクやスパム語句のスコア、訪問者ごとのレート制限です。それでもスパムが通るときだけ Cloudflare Turnstile などの人間確認を追加し、そのトークンはサーバーで検証します。捕まえたものは捨てずに保存しておけば、まれに紛れた本物のメッセージを救い出せます。
公開ページのお問い合わせフォームには、インデックスされてから数日でゴミが届き始めます。そのほとんどはあなたを狙ったものではありません。スクリプトが Web を巡回して <form> を見つけ、すべての入力欄を埋めて送信し、どこかにリンクが載ることを期待しているのです。一部はソフトウェアが操作する本物のブラウザーから、さらに一部は同じ売り込み文を何千ものフォームに貼り付けるよう報酬を受けた人から届きます。種類ごとに効くチェックが違うので、1 つの手だけでは解決しません。
このガイドでは、それぞれの防御策が何を捕まえ、本物の訪問者にどんな負担をかけるかを、どのスタックでも動くコードとともに説明します。そのうえで、フォームバックエンドの Ikiform が同じ層をどう代わりに実行するかを紹介します。
層は安いものから順に
チェックはコストの順に並べます。リクエストを数えるのはほぼタダなので、ボディを読むより前に実行します。キャプチャのように外部サービスを呼ぶチェックは、安いチェックを通過してから実行します。内容のスコアは解析済みのフィールドが必要なので最後です。Ikiform はすべての送信でこの順番にチェックを実行します。
- フォームのエンドポイントに送信が届きます。
- まずレート制限が訪問者ごとの送信数を数えます。大量送信は、ボディを読む前に 429 で拒否されます。
- 許可ドメインが、送信元のページと登録したドメインを比較します。
- 人間確認がオンなら、送信には有効な Turnstile トークンが必要です。
- ハニーポットが埋められていると、スコアに 5 が加算されます。
- 時間、リンク、既知のスパム語句、使い捨てアドレス、繰り返しは、より小さな値を加算します。
- 5 以上なら送信はスパムとして保存されます。それ未満なら受け付けられ、メールと Webhook が送られます。
防御策の比較
どの防御策にも代償があります。この表では、それぞれが見逃すものと、声を聞きたい相手に求める負担を正直に示します。
| 防御策 | 止めるもの | 見逃すもの | 本物の訪問者への負担 |
|---|---|---|---|
| Honeypot | すべての入力欄を埋めるスクリプト | CSS を読むボット、非表示のフィールドを飛ばすボット、人間 | なし(スクリーンリーダーと自動入力から隠していれば) |
| タイムトラップ | 誰も入力できないほど速い送信 | 遅いボットと、待機するボット | なし(最低時間が数秒なら) |
| リンク数、スパム語句、Akismet などの内容のスコア | SEO の売り込み、リンクの貼り逃げ、カジノや薬のスパム | きれいに見えるスパム。リンク入りの本物のメッセージを誤判定することもある | なし(捕まえたものを確認していれば) |
| Rate limiting | 大量送信とループするスクリプト | ゆっくり分散したスパム | 1 つの IP を共有するオフィスやイベントは、厳しい制限に引っかかることがある |
| 許可ドメイン | 他の場所からエンドポイントに直接送られる送信 | Origin ヘッダーを偽装するスクリプト | なし |
| Cloudflare Turnstile | ほとんどの自動化ブラウザー | 報酬を受けた人間のスパマー | たいていは見えない。ときどきクリックが必要で、スクリプトも要る |
| reCAPTCHA や hCaptcha | ほとんどの自動化ブラウザー | 報酬を受けた人間のスパマーと解読サービス | 一部の訪問者に画像パズル、加えてサードパーティのスクリプト |
まずは最初の 5 つから始めましょう。どれも目に見えず、小さなサイトに届くものの大半を捕まえます。人間確認は、まだスパムが通っているとダッシュボードでわかってから追加します。すべての訪問者が負担を感じる唯一の層だからです。キャプチャなしでスパムを止めるページでは、目に見えない層だけでどこまで防げるかを紹介しています。
戦う前にスパムを読む
対策を選ぶ前に、スパムのメッセージを 10 件見てください。共通点から送り主がわかり、それを止める層がわかります。
- すべてのフィールドにでたらめな文字が入っているのは、すべての入力欄を埋めるスクリプトです。ハニーポットで捕まえられます。
- 1 つの送信元から 1 分に何十件も届くのは、ループするスクリプトです。レート制限で止められます。
- ページの読み込みから 1 秒以内に届く整った送信は、ヘッドレスブラウザーです。タイムトラップか人間確認で止められます。
- リンク付きの丁寧な売り込みは、フォーム 1 件ごとに報酬を受ける人からです。捕まえられるのは内容のスコアと自分の目での確認だけです。
層を組み込んだお問い合わせフォーム
ここでは 1 つのお問い合わせフォームを各スタック向けに作ります。どの版にも画面外のハニーポットと人間確認があります。ブラウザーだけの版は、キーが要らない Ikiform のマネージド Turnstile チェックを使います。サーバーの版は自前で組む方法で、サーバーが自分の Cloudflare のキーで Turnstile トークンを検証してから、フィールドを転送します。下でスタックを選んでください。選択はこのガイドのすべてのコードサンプルに引き継がれます。
サーバーのタブでは、小さなヘルパーを 2 つ共有しています。verifyTurnstile はトークンが本物かを Cloudflare に問い合わせ、sendToIkiform はシークレットキーと訪問者の Origin ヘッダーを付けてフィールドを転送します。サーバー側の fetch は自分では Origin を送りませんが、Ikiform は無料の localhost テストを見分けるのにそれを読みます。どちらも安全側に失敗します。Cloudflare に接続できなければ、黙って通すのではなく訪問者にエラーを表示します。
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." };
}
}プレーンなフォームにはスクリプトが要りません。Ikiform のフォームの Spam check を Ikiform に設定すると、トークンのない送信には ikiform.com 上の短い確認ページが表示され、そこで Turnstile を実行してフィールドを転送します。見えないまま通過した人は気づきもしません。
<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 で、スクリーンリーダーとキーボードを近づけないようにします。
Server Action が自分のキーで Turnstile トークンを検証し、Ikiform のシークレットキーでフィールドを転送します。ハニーポットはそのまま渡すので、埋められていればスパムとして保存され、あとで確認できます。上のヘルパーを lib/spam.ts として保存します。TURNSTILE_SECRET_KEY と IKIFORM_SECRET_KEY をサーバー専用の環境変数として設定し、Ikiform のフォームの Spam check は Off のままにします。人間確認はすでにサーバーで済んでいるからです。
"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 };
}"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>
);
}"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 フォームのスパム対策 を読んでください。
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 のままにします。人間確認はすでにサーバーで済んでいるからです。
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 };
},
}),
};---
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 フォームのスパム対策 を読んでください。
createServerFn の POST 関数が getRequestHeader で訪問者の IP を読み、トークンを検証してフィールドを転送します。ウィジェットのコンポーネントは Next.js のタブと同じもので、src/components/turnstile.tsx に置きます。TURNSTILE_SECRET_KEY と IKIFORM_SECRET_KEY をサーバー専用の環境変数として設定し、Ikiform のフォームの Spam check は Off のままにします。人間確認はすでにサーバーで済んでいるからです。
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 フォームのスパム対策 を読んでください。
ルートの action がトークンを検証し、送信を転送します。actionData を reset として渡すと、応答のたびに新しいウィジェットがレンダリングされます。ウィジェットのコンポーネントは Next.js のタブのものです。TURNSTILE_SECRET_KEY と IKIFORM_SECRET_KEY をサーバー専用の環境変数として設定し、Ikiform のフォームの Spam check は Off のままにします。人間確認はすでにサーバーで済んでいるからです。
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 フォームのスパム対策 を読んでください。
デフォルトのフォーム 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 のままにします。人間確認はすでにサーバーで済んでいるからです。
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;<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 フォームのスパム対策 を読んでください。
サーバールートがトークンを検証し、送信を転送します。上のヘルパーを server/utils/spam.ts として保存すると、Nuxt が自動でインポートします。コンポーネントは Turnstile を明示的にレンダリングし、試行が失敗したらリセットします。TURNSTILE_SECRET_KEY と IKIFORM_SECRET_KEY をサーバー専用の環境変数として設定し、Ikiform のフォームの Spam check は Off のままにします。人間確認はすでにサーバーで済んでいるからです。
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 };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
turnstileSecretKey: "",
public: {
turnstileSiteKey: "",
},
},
});<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 フォームのスパム対策 を読んでください。
シングルページアプリには Turnstile のシークレットを保管するサーバーがないので、代わりに Ikiform のマネージドチェックを使います。@ikiform/sdk の challenge() は ikiform.com の小さなフレームを埋め込み、Turnstile がクリックを求めない限り見えないまま、submit() 用のトークンを返します。Spam check を Ikiform に設定し、Allowed domains に自分のドメインを追加してください。
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 の単一ファイルコンポーネントで使う例です。フレームは、訪問者がクリックする必要があるときだけ空の div の中に表示されます。
<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 のガイドにあります。
IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。下のフィールド定義を試してみてください。Ikiform のバリデーターがブラウザーで動きます。スパムスコアを数える前にサーバーが適用するのと同じルールです。
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
422 validation_failed で拒否します。ハニーポット
ハニーポットとは、人には見えず、ボットが埋めてしまう入力欄です。値が入っていれば、その送信はソフトウェアによるものです。訪問者には何の負担もなく、HTML を解析して見つけた入力欄をすべて埋める、最も古くからある一般的なボットを捕まえます。
仕掛けそのものより、うまく隠すことのほうが大切です。雑なハニーポットでは 3 つの問題が起きます:
- スクリーンリーダーが読み上げてしまう。 目の見えない訪問者が余分なフィールドを聞いて入力し、スパム扱いされます。
aria-hidden="true"で包み、空のままにするよう書いたラベルを付けてください。 - キーボードで止まってしまう。 Tab キーでフォームを移動すると、見えない入力欄で止まります。
tabindex="-1"で Tab 順から外します。 - 自動入力で埋まってしまう。 ブラウザーやパスワードマネージャーは名前でフィールドを埋めます。
email2やphoneと名付けると、本物の人の入力欄が埋められてしまうことがあります。_gotchaのように自動入力が認識しない名前を使い、autocomplete="off"を付けてください。
ハニーポットのガイドでは、隠し方、ボットの見破り方、各スタックでのサーバー側のチェックを解説しています。Ikiform では、ハニーポットが埋められるとスコアに 5 が加算され、それだけでしきい値の 5 に達します。フィールド名はフォームごとに Honeypot field で変更できます。
時間
人がお問い合わせフォームを埋めるには数秒かかります。多くのボットは数ミリ秒です。タイムトラップはフォームをレンダリングした時刻を記録し、送信時にその差を確認します。素朴な版では、非表示のフィールドにタイムスタンプを入れます:
const started = Number(formData.get("started"));
const seconds = (Date.now() - started) / 1000;
const tooFast = !Number.isFinite(seconds) || seconds < 3;ボットは好きな数値を送れるので、素のタイムスタンプで止められるのは手を抜いたボットだけです。サーバーでシークレットを使って署名すれば、ボットはレンダリング時刻を遅く偽装できません。署名付きの版はキャプチャなしでスパムを止める ページで作っています。
フォームが SDK や shadcn/ui コンポーネントから作られている場合、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 でスパムとして保存するので、あとで復元できます。両方の流れはスパムのドキュメントにあります。Turnstile と Google の reCAPTCHA や hCaptcha を比べているなら、reCAPTCHA の代替を参照してください。
レート制限と許可ドメイン
レート制限は、1 人の訪問者が送信できる頻度に上限を設けます。スパムとメールを見分けるのではなく、量を止めるものです。IP とフォームごとに数え、少しのバーストは許し、Retry-After ヘッダー付きの 429 で応答します。レート制限のガイドでは各スタック向けに実装し、サーバーレスのホストでメモリ上のカウンターが機能しない理由を説明しています。
Ikiform は、ボディを読む前にすべての送信を正確に数えます。各フォームでプリセットを選べます。Standard は訪問者ごとに 1 分 5 件、Relaxed は訪問者ごとに 1 分 20 件、Strict は訪問者ごとに 10 分 2 件です。Standard ではさらに 10 秒に最大 3 件まで、フォーム全体で 1 分 300 件までです。誠実な人が 10 分に 2 回書くことはないので、お問い合わせフォームには Strict が向いています。数値はレート制限にあります。
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 つを、そのケースのコード付きでさらに詳しく解説しています。
- ハニーポットフィールドスクリーンリーダー、キーボード、自動入力の邪魔をせずにボットを捕まえるハニーポットを、各スタックのサーバー側チェックとあわせてどんなフォームにも追加します。
- Cloudflare Turnstileプレーンな HTML フォームに Cloudflare Turnstile を追加します。ウィジェット、サーバー側の siteverify、テスト用キー、自前のキーやサーバーなしで動かす方法まで。
- CAPTCHA なしでCAPTCHA をまったく使わずにフォームスパムの大半を防ぐ方法。ハニーポット、署名付きの表示時刻、内容のスコア、レート制限を単独ではなく合算して使います。
- reCAPTCHA の代替Turnstile や hCaptcha から Proof of Work、CAPTCHA なしまで、フォーム向け reCAPTCHA の代替を比較し、小さく安全な変更で乗り換えます。
- レート制限共有ストアで訪問者ごとにフォーム送信のレート制限をかけ、Retry-After 付きの 429 を返し、1 つの IP を共有するオフィスを締め出さないようにします。
- 静的サイトシークレットを保持できず送信数も数えられない静的サイトのフォームでも、ハニーポットのパーシャル、安全なスクリプト、サーバー側のチェックでスパムを防ぎます。
- Webflow フォームのスパムWebflow 独自のスパム設定が働かない、フォームバックエンドに送信する Webflow フォームを、画面外のハニーポットと人間確認で守ります。
- Next.jsNext.js のフォームをスパムから守ります。ハニーポット、サーバーアクションで確認する Turnstile、Vercel・Netlify・Cloudflare での IP ごとの制限、それに合わせた CSP。
- AstroAction で Astro のフォームをスパムから守ります。ハニーポット、astro:env のシークレットで確認する Turnstile、IP ごとの制限、Astro 組み込みの CSP。
- TanStack StartTanStack Start のフォームをスパムから守る方法。クライアント IP 用の関数ミドルウェア、トークンバケット、サーバー関数での Turnstile 検証、CSP。
- React RouterReact Router のフォームをスパムから守る方法。Turnstile 付きのルート Action、cf-connecting-ip をキーにした Workers のレート制限、.server モジュール、CSP ヘッダー。
- SvelteKitSvelteKit のフォームをスパムから守る方法。getClientAddress を使うフォーム Action、IP ごとの制限、use:enhance の裏の Turnstile、それを許可する CSP。
- NuxtNuxt のフォームをスパムから守る方法。Turnstile 付きの Nitro サーバールート、Nitro ストレージでの IP ごとの制限、runtimeConfig のシークレット、routeRules で設定する CSP。
動作を確認する
本番で頼る前に、テスト用のフォームで次のことを試してください。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースでも localhost からのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 を購入します。クレジットに有効期限はありません。料金を参照してください。
- フォームの Allowed domains に自分のサイトのドメインを、開発中は
localhostも追加します。空のままにすると、どこからの送信でも受け付けます。 - ページのフォームに入力して送信します。自分のサンクスページか、Ikiform がホストするサンクスページに移動します。
- ダッシュボードで Submissions を開きます。送ったすべてのフィールドを持つ新しい送信データがあります。
- ハニーポットを
_gotcha=botと埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 - 名前のフィールドにリンクを、メッセージに語句
seo servicesを入れて送信します。理由url_in_nameとkeywordでスパムとして保存されます。 - Spam check を Ikiform にして、プレーンな HTML フォームを送信します。一瞬確認ページが表示され、その後サンクスページに移動します。
- フォームを続けて何回か送信します。制限を超えるとフォームは
429を返し、訪問者には Ikiform の「少し待ってください」ページが表示されます。 - 何も届きませんか?ダッシュボードのステータスフィルターを 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 や開発の売り込みです。ボット向けのチェックでは人を止められません。内容のスコアがリンクや語句で多くを捕まえ、残りはスパムフォルダーで削除します。
いいえ。スパムにも本物のメッセージと同じ成功の応答を返すので、ボットは何も学べません。30 日間保存され、メールも Webhook も送らず、ダッシュボードからスパムではないとマークできます。
いいえ。フォームの Spam check を Ikiform に設定すれば、キーを管理することなく ikiform.com で Turnstile が動きます。自分のページに自分のウィジェットを置きたい場合だけ、自分の Turnstile キーを使ってください。
雑に隠すと損ないます。ラッパーに aria-hidden を付けて画面外に隠し、tabindex -1 で Tab 順から外し、ブラウザーが自動入力しない名前を付けてください。
いいえ、無料プランはありません。テストは無料で、支払い前でも localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 を購入できます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com