# CAPTCHA なしでフォームスパムを防ぐ方法

> CAPTCHA をまったく使わずにフォームスパムの大半を防ぐ方法。ハニーポット、署名付きの表示時刻、内容のスコア、レート制限を単独ではなく合算して使います。

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

目に見えない 4 つのチェックを組み合わせ、見つかったものを合計します。ハニーポット、フォームが開かれていた時間を示す署名付きのタイムスタンプ、リンクやスパム的な語句のスコア、訪問者ごとのレート制限です。合計がしきい値を超えたときだけスパムとして扱えば、弱いシグナル 1 つで本物の人がブロックされることはありません。捕まえたものは残しておき、スパムフォルダーを見てまだボットが通り抜けているときだけ、後から CAPTCHA を追加します。

## CAPTCHA がない場合の違い

CAPTCHA は強力なイエスかノーを 1 つ返します。CAPTCHA がなければ、代わりに弱いシグナルが複数あるだけで、どれも単独ではかわすのも、うっかり引っかかるのも簡単です。速い送信はボットかもしれませんし、用意したメッセージを貼り付けた人かもしれません。リンクはスパムかもしれませんし、ポートフォリオかもしれません。コツはスコアにすることです。各シグナルに重みを付けて合計し、しきい値を超えたときだけスパムとみなします。唯一の強いシグナルである、埋められたハニーポットには、単独でしきい値を超えるだけの重みを与えます。

図: 3 種類の送信で、目に見えないシグナルが合算されて判定になる様子

1. スクリプトのボットはハニーポットを埋めて 5、1 秒未満で送信して 3 が加わります。合計 8 でスパムです。
2. 貼り付けられた SEO の売り込みは、名前フィールドのリンクで 3、既知のスパム語句で 2 です。合計 5 で、しきい値の 5 に達します。
3. リンクが 3 つある本物のメッセージはスコア 2 だけなので、受信されます。

重みは Ikiform のもので、しきい値は 5 です。弱いシグナルが 2 つ重なると超えますが、1 つでは超えません。

## 署名付きの表示時刻

人がフォームを埋めるには数秒かかります。タイムトラップはその間隔を測ります。弱い方法は `Date.now()` を hidden フィールドに入れるもので、どんなボットでも書き換えられます。強い方法は、サーバーでシークレットを使って表示時刻に署名するもので、ボットはページを実際より前に開いたと偽れません。このファイルは Web Crypto があればどこでも動きます。Node 20 以降、Deno、Bun、Workers、あらゆるサーバーレスのランタイムです。

`lib/render-token.ts`:

```ts
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 の場合:

`src/pages/contact.astro`:

```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` のような広い単語は本物の顧客まで引っかけます。

`lib/score.ts`:

```ts
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 };
}
```

`src/pages/api/contact.ts`:

```ts
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);
};
```

ハンドラーはスパムを捨てずにすべてをステータス付きで保存し、どちらにも同じリダイレクトで応答します。スパムは後で確認でき、ボットはどのシグナルで捕まったのかを知ることができません。このルートの手前に[訪問者ごとのレート制限](https://ikiform.com/ja/guides/spam-protection/rate-limiting.md) を置けば、どの処理も走る前に大量送信を止められます。

## 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 コンポーネント](https://ikiform.com/ja/docs/shadcn.md)は自動で送ります。自分でレンダリングするフォームでは、`@ikiform/sdk` の `getSchema()` で取得し、`_ikiform_ts` として送り返せます:

`contact.js`:

```js
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 通届きます。詳しくは[スキーマエンドポイント](https://ikiform.com/ja/docs/shadcn.md#schema-endpoint)と[スパムのドキュメント](https://ikiform.com/ja/docs/spam.md#scoring)を参照してください。

> それでも一部のボットが通り抜けるなら、**Spam check** を **Ikiform** にします。キーなしで Cloudflare Turnstile が加わり、ほとんどの訪問者には何も表示されません。CAPTCHA を追加するのはそのタイミング、つまりスパムフォルダーがそう示したときであって、それより前ではありません。

## 動作を確認する

1. フォームを読み込み、1 秒以内に送信します。自分のハンドラーなら、送信は `too_fast` 付きでスパムとして保存されます。
2. ブラウザーの開発者ツールで hidden の `rendered` の値を書き換えて送信します。`invalid_render` としてスコアが付きます。
3. ハニーポットを `_gotcha=bot` と埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
4. リンクを 3 つ含み、他に怪しい点のないメッセージを送ります。受信されます。
5. 何も届きませんか？ダッシュボードのステータスフィルターを **Spam** に切り替えてください。スパムにも本物の送信データと同じ成功の応答が返ります。

## ハマりどころ

- **静的ページ。** デプロイ時に 1 回だけビルドされるページは、すべての訪問者で表示時刻が同じになります。リクエストごとに署名するか、フォームが表示されたときに JavaScript で新しいトークンを取得してください。
- **パスワードマネージャーと貼り付け。** 自動入力で 1 秒でフォームを埋める人もいます。時間計測は弱いシグナルのままにして、それだけでブロックしないようにしてください。
- **語句リストは古くなります。** スパムの語句は変わります。数週間ごとに通り抜けたものを確認し、一般的な単語ではなく具体的な語句を追加してください。
- **繰り返しは普通のことです。** 返事が来なければ人は再送します。同一内容の繰り返しには重みを付けますが、即座にブロックはしないでください。

## よくある質問

### CAPTCHA なしでフォームスパムを防げますか？

大半は防げます。ハニーポット、署名付きの表示時刻、内容のスコア、レート制限で、小規模なサイトに届くスパムの大部分を、本物の訪問者に負担をかけずに止められます。完全なブラウザーを動かして待つボットは通り抜けることがあり、そのときは Turnstile のような目に見えない確認が役立ちます。

### タイムトラップはどれくらい待つべきですか？

お問い合わせフォームなら 3 秒程度です。短くすると速いボットを見逃し、長くすると用意したメッセージを貼り付ける人や自動入力を使う人まで捕まえ始めます。厳格なブロックではなく、複数あるシグナルの 1 つとして使ってください。

### なぜタイムスタンプに署名するのですか？

hidden フィールドに入れただけのタイムスタンプは、どんなボットでも古く見えるように書き換えられます。サーバーでシークレットを使って署名すれば、有効な表示時刻を作れるのはあなたのサーバーだけになります。

### Ikiform でスパムを除外するのに CAPTCHA は必要ですか？

いいえ。ハニーポット、署名付きの時間計測、内容のスコア、レート制限は、スパムチェックがオフでもすべてのフォームで動きます。マネージドの Turnstile による確認は、それでもスパムが通り抜けるときにフォームごとにオンにする追加機能です。

## その他のガイド

- [お問い合わせフォームのスパムを止める](https://ikiform.com/ja/guides/spam-protection.md): 本物の訪問者に負担をかけない層でお問い合わせフォームのスパムを止めます。ハニーポット、時間、内容のスコア、レート制限、必要なときだけの人間確認です。
- [ハニーポットフィールド](https://ikiform.com/ja/guides/spam-protection/honeypot.md): スクリーンリーダー、キーボード、自動入力の邪魔をせずにボットを捕まえるハニーポットを、各スタックのサーバー側チェックとあわせてどんなフォームにも追加します。
- [レート制限](https://ikiform.com/ja/guides/spam-protection/rate-limiting.md): 共有ストアで訪問者ごとにフォーム送信のレート制限をかけ、Retry-After 付きの 429 を返し、1 つの IP を共有するオフィスを締め出さないようにします。
- [reCAPTCHA の代替](https://ikiform.com/ja/guides/spam-protection/recaptcha-alternatives.md): Turnstile や hCaptcha から Proof of Work、CAPTCHA なしまで、フォーム向け reCAPTCHA の代替を比較し、小さく安全な変更で乗り換えます。
- [Cloudflare Turnstile](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md): プレーンな HTML フォームに Cloudflare Turnstile を追加します。ウィジェット、サーバー側の siteverify、テスト用キー、自前のキーやサーバーなしで動かす方法まで。
- [静的サイト](https://ikiform.com/ja/guides/spam-protection/static-site.md): シークレットを保持できず送信数も数えられない静的サイトのフォームでも、ハニーポットのパーシャル、安全なスクリプト、サーバー側のチェックでスパムを防ぎます。
