お問い合わせフォームのスパムを止める1 分で読めます
ボットだけが埋めて人は埋めないハニーポットフィールドを追加する方法
スクリーンリーダー、キーボード、自動入力の邪魔をせずにボットを捕まえるハニーポットを、各スタックのサーバー側チェックとあわせてどんなフォームにも追加します。
テキスト入力欄を 1 つ追加し、画面外に隠して Tab 順から外し、aria-hidden で包み、どのブラウザーも自動入力しない名前を付けます。人は決して入力しませんが、すべての入力欄を埋めるボットは入力します。サーバーでは値が入った送信をすべてスパムとして扱い、本物のメッセージとまったく同じように応答します。
ハニーポットならではの点
ハニーポットは 2 つの顔を同時に持つ必要があります。HTML を読むボットには、埋める価値のある普通のフィールドに見えなければなりません。そしてすべての人にとっては、存在しないものでなければなりません。それには、あなたとは違う方法でフォームを使う人も含まれます。スクリーンリーダーを使う人、キーボードで Tab 移動する人、パスワードマネージャーにフォームを埋めてもらう人です。ハニーポットの不具合のほとんどはこうした人の誰かを忘れることから起き、その症状は本物のメッセージが黙ってスパムに振り分けられることです。
- 人は画面、キーボード、スクリーンリーダーなど、自分の使う手段でフォームに入力します。
- 罠は画面外にあり、Tab 順から外れ、支援技術からも隠れているので、人がそこに行き着くことはありません。
- ブラウザーやパスワードマネージャーが自動入力しない名前なので、空のままです。
- 送信は空の罠を持って届き、受け付けられます。
- スクリプトのボットは HTML を解析し、罠を含むすべての入力欄を見つけます。
- すべてを埋めて、すぐに送信します。
- 罠に値があるので、その送信はスパムです。それでもボットには普通の成功の応答が返ります。
マークアップ
罠はフォームの中、本物のフィールドの隣に、普通のテキスト入力欄として置きます。type="hidden" の入力欄では機能しません。人が編集できないものだとボットは知っていて、手を付けないからです。
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<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" required></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</button>
</form><style>
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>- ラッパーに
aria-hidden="true"を付けると、ラベルと入力欄がアクセシビリティツリーから外れ、スクリーンリーダーが読み上げなくなります。 tabindex="-1"で、誰にも見えない入力欄に Tab キーで止まらないようにします。- ラベルには Leave this empty(空のままにしてください)と書きます。何かのツールがフィールドを見せてしまっても、読んだ人は何をすべきかわかります。
requiredは付けないでください。人に見えない必須フィールドは、誠実な送信をすべて妨げます。
隠し方
ボットは、明らかに隠されたフィールドを飛ばすことを覚えました。単純なスクレイパーはいまだにすべてを埋めますが、入力前に hidden 属性やインラインの display: none を確認するものもあります。隠し方が入力欄そのものから遠いほど、多くのボットが引っかかります。
| 手法 | 人から隠れるか | まだ捕まえられるボット |
|---|---|---|
hidden 属性かインラインの display: none | はい | 最も単純なものだけ。賢いボットが最初に確認する点 |
| スタイルシートの画面外クラス | はい | ほとんど。見分けるには、ボットが CSS を読み込んで適用する必要がある |
opacity: 0 やサイズ 0 | ほぼ隠れる。拡大鏡やフォーカスの枠で見えてしまうことがある | ほとんど。ただし人にとってのリスクは高め |
| 読み込み後に JavaScript で追加 | はい | スクリプトを実行するボット。素の HTML スクレイパーには見えないので、それらは逃す |
スタイルシートの画面外クラスを使ってください。最も多くのボットを捕まえ、人にとっても最も安全です。ハニーポットが効かなくなったら、もっと巧妙な隠し方を考えるより、別の名前で 2 つ目を追加してください。
自動入力が無視する名前を選ぶ
ブラウザーやパスワードマネージャーは、name、id、ラベル、autocomplete の値から推測してフィールドを埋めます。autocomplete="off" はヒントにすぎず、住所や連絡先に見えるフィールドでは無視するブラウザーもあります。だから実際に効くのは名前です。
| 避ける名前 | 理由 | 代わりに使う名前 |
|---|---|---|
email2, phone, name, address | 自動入力が認識し、本物の人の入力欄を埋めてしまう | _gotcha, company_website |
honeypot, trap, bot | 見え見えの名前のフィールドを飛ばすボットがいる | 地味でありそうな名前 |
url や website 単独 | 保存したプロフィールから Web サイトのフィールドを埋める自動入力ツールがある | company_website のような複合語の名前 |
サーバーで確認する
ハニーポットは、サーバーが読んで初めて機能します。ボットはスクリプトを実行しないので、ブラウザー側のチェックは無意味です。フィールドに値があるときは、本物のメッセージとまったく同じように応答し、送信を保存も転送もしないでください。違う応答を返すと、どのフィールドでばれたのかをボットに教えてしまいます。以下の例では自前の saveMessage を呼んでいます。メッセージを保存したりメールしたりする処理に置き換えてください。
プレーンな HTML フォームには自前のサーバーがありません。送信先を Ikiform にして罠を _gotcha と名付けるか、フォームごとに Honeypot field で名前を変えてください。サーバー側のチェックは Ikiform が行い、罠が回答として保存されることはありません。
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<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" required></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</button>
</form>Server Action では、罠にかかった送信も本物と同じサンクスページにリダイレクトし、保存を飛ばします。
"use server";
import { redirect } from "next/navigation";
export async function sendMessage(formData: FormData) {
if (String(formData.get("company_website") ?? "").trim() !== "") {
console.info("honeypot", { at: new Date().toISOString() });
redirect("/contact/thanks");
}
await saveMessage({
email: String(formData.get("email") ?? ""),
message: String(formData.get("message") ?? ""),
});
redirect("/contact/thanks");
}Astro Action では、入力から罠を取り出し、値があれば通常の結果を返します。
import { defineAction } from "astro:actions";
import { z } from "astro/zod";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({
email: z.email(),
message: z.string().min(1),
company_website: z.string().nullish(),
}),
handler: async ({ company_website, ...fields }) => {
if (company_website?.trim()) return { sent: true };
await saveMessage(fields);
return { sent: true };
},
}),
};.validator() で罠を読み、ハンドラーで保存を飛ばします。どちらの経路も同じ応答を返します。
import { createServerFn } from "@tanstack/react-start";
export const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return {
email: String(data.get("email") ?? ""),
message: String(data.get("message") ?? ""),
trapped: String(data.get("company_website") ?? "").trim() !== "",
};
})
.handler(async ({ data: { trapped, ...fields } }) => {
if (!trapped) await saveMessage(fields);
return { ok: true };
});ルートの Action はどちらの場合もサンクスページにリダイレクトし、罠にかかっていない送信だけを保存します。
import { redirect } from "react-router";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
if (String(form.get("company_website") ?? "").trim()) return redirect("/contact/thanks");
await saveMessage({ email: String(form.get("email")), message: String(form.get("message")) });
return redirect("/contact/thanks");
}フォーム Action は罠が空のときだけ保存し、どちらの場合も 303 でリダイレクトします。
import { redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request }) => {
const form = await request.formData();
if (!String(form.get("company_website") ?? "").trim()) {
await saveMessage({ email: String(form.get("email")), message: String(form.get("message")) });
}
redirect(303, "/contact/thanks");
},
} satisfies Actions;サーバールートは、罠にかかった送信にも本物にも、同じボディで 201 を返します。
export default defineEventHandler(async (event) => {
const body = await readBody<{ email?: string; message?: string; company_website?: string }>(event);
if (body.company_website?.trim()) {
setResponseStatus(event, 201);
return { sent: true };
}
await saveMessage({ email: body.email ?? "", message: body.message ?? "" });
setResponseStatus(event, 201);
return { sent: true };
});シングルページアプリは罠を自分で確認できません。他のフィールドと一緒に送り、送信先のサーバーに判断させてください。FormData なら追加のコードなしで含まれます。
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});Vue でも同じです。new FormData(form) に含まれるよう罠を <form> の中に置き、サーバーに確認させてください。
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});Ikiform でのハニーポットの扱い
Ikiform のフォームには、デフォルトで _gotcha という名前のハニーポットがあります。フォームの設定の Honeypot field でフォームごとに変更でき、選んだ名前は予約され、回答として保存されることはありません。ハニーポットが埋められるとスパムスコアに 5 が加算され、それだけでしきい値の 5 に達します。
Ikiform は送信を捨てません。スパムとして残すので、本物の人が罠にかかってしまっても、メッセージを見つけて復元できます。スパムと判定された送信にも本物と同じ成功の応答を返すので、ボットは何も学べません。ステータス spam で保存され、通知メールも Webhook も送らず、使用量にもカウントされず、30 日後に削除されます。スパムをブロックしたワークスペースには毎週月曜日に、合計、最も多かったフォーム、主な理由をまとめたメールが 1 通届きます。リファレンスはスパムのドキュメントと HTML フォームにあります。
動作を確認する
- スクリーンリーダーをオンにしてフォームを読み込み、順に移動します。罠が読み上げられることはありません。
- Tab キーでフォームを移動します。フォーカスは最後の本物のフィールドからボタンに直接移ります。
- ブラウザーとパスワードマネージャーにフォームを自動入力させます。罠は空のままです。
- ハニーポットを
_gotcha=botと埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 - 普通のメッセージを送ります。受け付けられて届き、罠のフィールドの痕跡はありません。
ハマりどころ
- JSON の送信。
FormDataを使わずにボディを手で組み立てる場合は罠も含めてください。含めないと、サーバーはボットの場合も空として読みます。 - フィールド名を変えるフォームビルダー。 入力欄に接頭辞を付けたり名前を変えたりするビルダーがあります。罠の本当の名前は、エディターではなく実際に送信されたボディで確認してください。
- キャッシュ。 名前を変えた後もキャッシュされたページに古い罠の名前が残っていると、古い名前で送信が届いて通過してしまいます。両側で同時に名前を変えてください。
- チェックボックスのハニーポット。 非表示のチェックボックスでも機能しますが、スクリーンリーダーは余計なチェックボックスをテキスト入力より上手に扱えません。テキスト入力欄を選んでください。
- 層の 1 つ。 本物のブラウザーを動かして見えないフィールドを飛ばすボットは、どんなハニーポットも素通りします。レート制限と、必要なら人間確認 を組み合わせてください。
よくある質問
はい、最も一般的なもの、つまり見つけた入力欄をすべて埋めるスクリプトには効きます。フルブラウザーを動かして見えないフィールドを飛ばすボットは通り抜けるので、ハニーポットは唯一の層ではなく最初の層として使ってください。
雑に隠した場合だけです。aria-hidden を付けた要素で包み、入力欄に tabindex -1 を付け、Leave this empty とラベルを付け、required にはしないでください。そうすればスクリーンリーダーもキーボードも飛ばします。
たいていは自動入力です。email2 や phone のように名前が本物のフィールドに見えたので、ブラウザーやパスワードマネージャーが罠を埋めてしまったのです。どの自動入力も認識しない名前に変えてください。
いいえ。罠にかかった送信にも本物とまったく同じように応答してください。エラーを返すとどのフィールドで捕まったかをボットに教えてしまい、次の版のボットはそのフィールドを飛ばします。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com