お問い合わせフォームのスパムを止める2 分で読めます
CAPTCHA なしでフォームスパムを防ぐ方法
CAPTCHA をまったく使わずにフォームスパムの大半を防ぐ方法。ハニーポット、署名付きの表示時刻、内容のスコア、レート制限を単独ではなく合算して使います。
目に見えない 4 つのチェックを組み合わせ、見つかったものを合計します。ハニーポット、フォームが開かれていた時間を示す署名付きのタイムスタンプ、リンクやスパム的な語句のスコア、訪問者ごとのレート制限です。合計がしきい値を超えたときだけスパムとして扱えば、弱いシグナル 1 つで本物の人がブロックされることはありません。捕まえたものは残しておき、スパムフォルダーを見てまだボットが通り抜けているときだけ、後から CAPTCHA を追加します。
CAPTCHA がない場合の違い
CAPTCHA は強力なイエスかノーを 1 つ返します。CAPTCHA がなければ、代わりに弱いシグナルが複数あるだけで、どれも単独ではかわすのも、うっかり引っかかるのも簡単です。速い送信はボットかもしれませんし、用意したメッセージを貼り付けた人かもしれません。リンクはスパムかもしれませんし、ポートフォリオかもしれません。コツはスコアにすることです。各シグナルに重みを付けて合計し、しきい値を超えたときだけスパムとみなします。唯一の強いシグナルである、埋められたハニーポットには、単独でしきい値を超えるだけの重みを与えます。
- スクリプトのボットはハニーポットを埋めて 5、1 秒未満で送信して 3 が加わります。合計 8 でスパムです。
- 貼り付けられた SEO の売り込みは、名前フィールドのリンクで 3、既知のスパム語句で 2 です。合計 5 で、しきい値の 5 に達します。
- リンクが 3 つある本物のメッセージはスコア 2 だけなので、受信されます。
署名付きの表示時刻
人がフォームを埋めるには数秒かかります。タイムトラップはその間隔を測ります。弱い方法は Date.now() を hidden フィールドに入れるもので、どんなボットでも書き換えられます。強い方法は、サーバーでシークレットを使って表示時刻に署名するもので、ボットはページを実際より前に開いたと偽れません。このファイルは Web Crypto があればどこでも動きます。Node 20 以降、Deno、Bun、Workers、あらゆるサーバーレスのランタイムです。
const encoder = new TextEncoder();
async function key(secret: string) {
return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
"sign",
"verify",
]);
}
function toHex(bytes: ArrayBuffer) {
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function fromHex(hex: string) {
return new Uint8Array((hex.match(/../g) ?? []).map((pair) => Number.parseInt(pair, 16)));
}
export async function signRender(secret: string, now = Date.now()) {
const signature = await crypto.subtle.sign("HMAC", await key(secret), encoder.encode(`render.${now}`));
return `${now}.${toHex(signature)}`;
}
export type RenderCheck = "ok" | "too_fast" | "invalid";
export async function checkRender(secret: string, token: string, minSeconds = 3, now = Date.now()): Promise<RenderCheck> {
const [time = "", signature = ""] = token.split(".");
if (!/^\d{13}$/.test(time) || !/^[0-9a-f]{64}$/.test(signature)) return "invalid";
const valid = await crypto.subtle.verify("HMAC", await key(secret), fromHex(signature), encoder.encode(`render.${time}`));
const age = now - Number(time);
if (!valid || age < 0 || age > 86_400_000) return "invalid";
return age < minSeconds * 1000 ? "too_fast" : "ok";
}署名はページの表示時に行うので、ページは静的 HTML としてキャッシュせず、リクエストごとにレンダリングする必要があります。Astro の場合:
---
import { signRender } from "../lib/render-token";
export const prerender = false;
const rendered = await signRender(import.meta.env.RENDER_SECRET);
---
<form method="POST" action="/api/contact">
<input type="hidden" name="rendered" value={rendered} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="name">Name</label>
<input id="name" name="name" required />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button>Send</button>
</form>- お問い合わせフォームなら最低 3 秒が安全です。用意したメッセージを貼り付ける人でも 2 秒はかかることがあります。
- 1 日より古いトークンは拒否します。そうしないと、ボットがページを 1 回読み込んで同じトークンを何週間も使い回せます。
- トークンがない、または偽造されているのはシグナルとして扱い、証拠とはみなしません。キャッシュされたページや古いタブでも起こりうるからです。
送信にスコアを付ける
シグナルを 1 つの関数にまとめます。リンクや語句のチェックは、ハニーポットや時間計測では捕まえられないもの、つまり人が入力したり貼り付けたりしたメッセージを捕まえます。語句のリストは短く具体的にしてください。free のような広い単語は本物の顧客まで引っかけます。
const WEIGHTS = { honeypot: 5, too_fast: 3, invalid_render: 3, link_in_name: 3, many_links: 2, phrase: 2 };
const THRESHOLD = 5;
const LINK = /https?:\/\/|www\./gi;
const PHRASES = /\b(seo services|guest post|backlinks|first page of google|crypto investment)\b/i;
type Reason = keyof typeof WEIGHTS;
export function scoreContact(input: {
name: string;
message: string;
trap: string;
render: "ok" | "too_fast" | "invalid";
}) {
const reasons: Reason[] = [];
if (input.trap.trim()) reasons.push("honeypot");
if (input.render === "too_fast") reasons.push("too_fast");
if (input.render === "invalid") reasons.push("invalid_render");
if (input.name.match(LINK)) reasons.push("link_in_name");
if ((`${input.name} ${input.message}`.match(LINK)?.length ?? 0) >= 3) reasons.push("many_links");
if (PHRASES.test(`${input.name} ${input.message}`)) reasons.push("phrase");
const score = reasons.reduce((total, reason) => total + WEIGHTS[reason], 0);
return { score, reasons, spam: score >= THRESHOLD };
}import type { APIRoute } from "astro";
import { checkRender } from "../../lib/render-token";
import { scoreContact } from "../../lib/score";
export const POST: APIRoute = async ({ request, redirect }) => {
const form = await request.formData();
const render = await checkRender(import.meta.env.RENDER_SECRET, String(form.get("rendered") ?? ""));
const verdict = scoreContact({
name: String(form.get("name") ?? ""),
message: String(form.get("message") ?? ""),
trap: String(form.get("_gotcha") ?? ""),
render,
});
await saveMessage(Object.fromEntries(form), verdict.spam ? "spam" : "received", verdict.reasons);
return redirect("/thanks", 303);
};ハンドラーはスパムを捨てずにすべてをステータス付きで保存し、どちらにも同じリダイレクトで応答します。スパムは後で確認でき、ボットはどのシグナルで捕まったのかを知ることができません。このルートの手前に訪問者ごとのレート制限 を置けば、どの処理も走る前に大量送信を止められます。
Ikiform は CAPTCHA なしでこれらを実行します
Ikiform のフォームはすべて、設定なしでハニーポット、時間計測、内容のスコア、レート制限を実行し、Spam check は最初 Off です。ハニーポットは _gotcha です。内容のスコアでは、リンク、既知のスパム語句 22 個、使い捨てメールのドメイン、24 時間以内の同一内容の繰り返しを確認します。レート制限は最初、訪問者ごとに 1 分あたり 5 件です。スコアが 5 以上の送信はスパムです。
| シグナル | 重み |
|---|---|
| ハニーポットが埋まっている | +5 |
| 署名付きの表示から 2 秒未満で送信 | +3 |
| 表示時刻が偽造されているか 24 時間より古い | +3 |
| 名前や件名のフィールドにリンクがある | +3 |
| 24 時間以内に同一のフィールド | +3 |
| リンクが 3 つ以上 | +2 |
| 既知のスパム語句 | +2 |
| 使い捨てメールのドメイン | +2 |
Ikiform の署名付き表示時刻は、フォームの公開スキーマから取得します。shadcn/ui コンポーネントは自動で送ります。自分でレンダリングするフォームでは、@ikiform/sdk の getSchema() で取得し、_ikiform_ts として送り返せます:
import { getSchema, submit } from "@ikiform/sdk";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const form = document.querySelector("form");
const schema = await getSchema(FORM_ID);
const rendered = schema.ok ? schema.schema.ts : null;
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(form);
if (rendered) data.set("_ikiform_ts", rendered);
const result = await submit(FORM_ID, data);
form.replaceWith(result.ok ? "Thanks, we'll be in touch." : result.error.message);
});プレーンな HTML フォームは表示時刻を送りませんが、それで減点されることはないので、なくても損はありません。スパムと判定された送信にも本物と同じ成功の応答を返すので、ボットは何も学べません。ステータス spam で保存され、通知メールも Webhook も送らず、使用量にもカウントされず、30 日後に削除されます。スパムをブロックしたワークスペースには、毎週月曜日に合計件数、件数の多いフォーム、主な理由をまとめたメールが 1 通届きます。詳しくはスキーマエンドポイントとスパムのドキュメントを参照してください。
動作を確認する
- フォームを読み込み、1 秒以内に送信します。自分のハンドラーなら、送信は
too_fast付きでスパムとして保存されます。 - ブラウザーの開発者ツールで hidden の
renderedの値を書き換えて送信します。invalid_renderとしてスコアが付きます。 - ハニーポットを
_gotcha=botと埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 - リンクを 3 つ含み、他に怪しい点のないメッセージを送ります。受信されます。
- 何も届きませんか?ダッシュボードのステータスフィルターを Spam に切り替えてください。スパムにも本物の送信データと同じ成功の応答が返ります。
ハマりどころ
- 静的ページ。 デプロイ時に 1 回だけビルドされるページは、すべての訪問者で表示時刻が同じになります。リクエストごとに署名するか、フォームが表示されたときに JavaScript で新しいトークンを取得してください。
- パスワードマネージャーと貼り付け。 自動入力で 1 秒でフォームを埋める人もいます。時間計測は弱いシグナルのままにして、それだけでブロックしないようにしてください。
- 語句リストは古くなります。 スパムの語句は変わります。数週間ごとに通り抜けたものを確認し、一般的な単語ではなく具体的な語句を追加してください。
- 繰り返しは普通のことです。 返事が来なければ人は再送します。同一内容の繰り返しには重みを付けますが、即座にブロックはしないでください。
よくある質問
大半は防げます。ハニーポット、署名付きの表示時刻、内容のスコア、レート制限で、小規模なサイトに届くスパムの大部分を、本物の訪問者に負担をかけずに止められます。完全なブラウザーを動かして待つボットは通り抜けることがあり、そのときは Turnstile のような目に見えない確認が役立ちます。
お問い合わせフォームなら 3 秒程度です。短くすると速いボットを見逃し、長くすると用意したメッセージを貼り付ける人や自動入力を使う人まで捕まえ始めます。厳格なブロックではなく、複数あるシグナルの 1 つとして使ってください。
hidden フィールドに入れただけのタイムスタンプは、どんなボットでも古く見えるように書き換えられます。サーバーでシークレットを使って署名すれば、有効な表示時刻を作れるのはあなたのサーバーだけになります。
いいえ。ハニーポット、署名付きの時間計測、内容のスコア、レート制限は、スパムチェックがオフでもすべてのフォームで動きます。マネージドの Turnstile による確認は、それでもスパムが通り抜けるときにフォームごとにオンにする追加機能です。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com