ガイド

ガイド4 分で読めます

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

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

Preet SutharIkiform 創業者

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

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

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

層は安いものから順に

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

送信が届いてから保存またはスパムになるまでの、スパムチェックの実行順その場で拒否スコアを付け、どちらでも保存送信が届くページ、スクリプト、サーバーからレート制限1人の訪問者から多すぎる:429許可ドメイン他のサイトからの送信?人間確認オンならTurnstileトークンハニーポット入力あり: +5時間と内容速すぎ、リンク、語句、繰り返しスコア 5以上?受け付けメールと Webhookを送信スパム同じ成功の応答、何も送らない件数オリジントークンいいえはい送信が届いてから保存またはスパムになるまでの、スパムチェックの実行順その場で拒否スコアを付け、どちらでも保存送信が届くページ、スクリプト、サーバーからレート制限1 人の訪問者から多すぎる: 429許可ドメイン他のサイトからの送信?人間確認オンなら Turnstile トークンハニーポット入力あり: +5時間と内容速すぎ、リンク、語句、繰り返しスコア 5 以上?受け付けメールと Webhook を送信スパム同じ成功の応答、何も送らない件数オリジントークンいいえはい
  1. フォームのエンドポイントに送信が届きます。
  2. まずレート制限が訪問者ごとの送信数を数えます。大量送信は、ボディを読む前に 429 で拒否されます。
  3. 許可ドメインが、送信元のページと登録したドメインを比較します。
  4. 人間確認がオンなら、送信には有効な Turnstile トークンが必要です。
  5. ハニーポットが埋められていると、スコアに 5 が加算されます。
  6. 時間、リンク、既知のスパム語句、使い捨てアドレス、繰り返しは、より小さな値を加算します。
  7. 5 以上なら送信はスパムとして保存されます。それ未満なら受け付けられ、メールと Webhook が送られます。
上の段は送信を拒否します。下の段は拒否せずスコアを付けるだけなので、誤判定があってもクリック 1 つで復元でき、メッセージは失われません。

防御策の比較

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

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

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

戦う前にスパムを読む

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

スパムの見た目から、それを止める層へ全フィールドにでたらめな文字スクリプトのボットHTML を解析し全入力欄を埋めるハニーポット1 分に数十件、送信元は 1 つループするスクリプトレート制限読み込み 1 秒以内の整った送信ヘッドレスブラウザータイムトラップ、人間確認リンク付きの丁寧な売り込みフォームごとに報酬を受ける人内容のスコア、目視確認見えるもの送り主止めるものスパムの見た目から、それを止める層へ全フィールドにでたらめな文字スクリプトのボットHTML を解析し全入力欄を埋めるハニーポット1 分に数十件、送信元は 1 つループするスクリプトレート制限読み込み 1 秒以内の整った送信ヘッドレスブラウザータイムトラップ、人間確認リンク付きの丁寧な売り込みフォームごとに報酬を受ける人内容のスコア、目視確認
  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
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 を実行してフィールドを転送します。見えないまま通過した人は気づきもしません。

contact.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 で、スクリーンリーダーとキーボードを近づけないようにします。

  • どの版も HTML タブの style ブロックにある .hp クラスを使います。グローバル CSS に入れてください。
  • ハニーポットの名前は、Ikiform のデフォルトである _gotcha です。サーバーの版はこれをそのまま転送するので、埋められていれば理由 honeypot でスパムフォルダーに入ります。
  • Turnstile はフォームに非表示の cf-turnstile-response 入力欄を追加します。トークンは 300 秒有効で 1 回しか使えないので、試行ごとに新しいトークンが必要です。
  • Cloudflare のテスト用キーを使えば、これらすべてをローカルで動かせます。一覧は Turnstile のガイドにあります。
サーバー側の版はシークレットキーで送信するので、Ikiform は Allowed domains と自身の人間確認をスキップします。すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分 5 件という Standard の制限は全訪問者の合計で数えられます。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

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

プレビュー

Ikiform のバリデーターをブラウザーで実行します。何も送信されません。

お問い合わせフォームのテンプレートと同じフィールドです。定義をフォームの Fields タブに貼り付けると、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)チャレンジなし。ハニーポット、時間、スコアは引き続き動く。なし
IkiformIkiform が 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 つを、そのケースのコード付きでさらに詳しく解説しています。

動作を確認する

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

  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_limited1 人の訪問者がフォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分 5 件までです。Retry-After の時間だけ待つか、共有の 1 つの IP から送信するフォームには Relaxed か Custom を選んでください。
403 captcha_failedTurnstile のトークンがない、期限切れ、または 2 回使われました。トークンの有効期間は 300 秒で 1 回しか使えないので、試行のたびに新しいウィジェットをレンダリングするか、challenge() を呼び直してください。
スパムが受け付けられたまま届き続ける1 件開いて、他との共通点を読んでください。スクリプトによるスパムなら Spam check を Ikiform に、人が入力する売り込みならより厳しいレート制限と毎週の確認が必要です。
本物の訪問者から送信できないと報告があるハニーポットがスクリーンリーダーから隠され、自動入力に無視される名前になっているか、また厳しいレート制限が 1 つのオフィス IP の背後にいる人を捕まえていないかを確認してください。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com