# 静的サイトのお問い合わせフォームでスパムを防ぐ方法

> シークレットを保持できず送信数も数えられない静的サイトのフォームでも、ハニーポットのパーシャル、安全なスクリプト、サーバー側のチェックでスパムを防ぎます。

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

静的サイトにはサーバーがないので、ページ上の何もシークレットキーを保持できず、CAPTCHA のトークンを検証したり、訪問者ごとの送信数を数えたりできません。これらのチェックは、ホストの関数か、フォームの送信先のフォームバックエンドなど、別の場所で実行する必要があります。どちらの場合も、フォームにはアクセシブルなハニーポットを置き、JavaScript なしでもフォームが動くようにしてください。

## 静的サイトならではの違い

静的サイトでは、デプロイしたものはすべて公開されます。HTML や JavaScript にあるものはボットに読まれ、迂回されるので、クライアント側のスパムチェックは強制力のない助言にすぎません。最も重要な 3 つのチェックは、どれもサーバーが必要です:

- **captcha トークンの検証** には、シークレットキーが必要で、公開ファイルには置けません。
- **訪問者ごとの送信数のカウント** には、リクエストをまたいで残るストレージが必要です。
- **ハニーポットの読み取り** には、送信が届いた後にボットの手の届かない場所で動くコードが必要です。

さらに、静的ホストはページへの送信を受け付けません。Vercel や GitHub Pages は静的ページへのフォームの `POST` に `405 Method Not Allowed` を返すので、スパム以前に、フォームにはエンドポイントが必要です。

図: 静的サイトのフォームをつなぐ 3 つの方法と、それぞれでスパムチェックが動く場所

1. 自分自身に送信する静的ページは、コードを実行しないホストに届くので、送信は 405 で失敗するか、どこにも届きません。
2. ホストの関数を使う場合、ページは関数に送信し、関数がシークレットを持ち、ハニーポットを読み、トークンを検証してからメッセージを送ります。
3. フォームバックエンドを使う場合、ページはバックエンドに直接送信し、バックエンドがハニーポット、スコア判定、レート制限、人間確認を実行してからメールで通知します。

動く 2 つの方法のどちらでも、チェックは送信がブラウザーを離れた後に実行されます。ボットが迂回できないのはそこだけです。

## 選択肢

| 選択肢 | スパムチェック | トレードオフ |
| --- | --- | --- |
| Netlify Forms | すべての送信に Akismet。追加すれば `netlify-honeypot` フィールドと reCAPTCHA v2 も | Netlify 専用。ホストを移るとフォームを作り直すことになる。[Netlify Forms の代替](https://ikiform.com/alternatives/netlify-forms)を参照 |
| ホスト上の関数 | 書いたものすべて。ハニーポット、トークンの確認、ストアを使うレート制限 | 自分で書いて保守する必要があり、背後にメールやストレージのサービスが必要 |
| Ikiform などのフォームバックエンド | ハニーポット、スコア判定、レート制限、許可ドメイン、人間確認をコードなしで | 別のサービスで、送信件数に応じた料金 |

## 再利用できるパーシャルとしてのフォーム

どのエンドポイントを選んでも、フォームはパーシャルやインクルードとして 1 回だけ書き、すべてのページで同じハニーポットを使います。以下の版は Ikiform に送信します。関数を使う場合は、`action` を関数のパスに変え、`_redirect` を削除してください。

**Hugo** (`layouts/partials/contact-form.html`)

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
  <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></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>
  <input type="hidden" name="_redirect" value="{{ "thanks/" | absURL }}" />
  <p role="status"></p>
  <button>Send message</button>
</form>
```

**Jekyll** (`_includes/contact-form.html`)

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
  <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></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>
  <input type="hidden" name="_redirect" value="{{ '/thanks/' | absolute_url }}" />
  <p role="status"></p>
  <button>Send message</button>
</form>
```

**Eleventy** (`_includes/contact-form.njk`)

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
  <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></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>
  <input type="hidden" name="_redirect" value="{{ site.url }}/thanks/" />
  <p role="status"></p>
  <button>Send message</button>
</form>
```

`assets/contact.css`:

```html
<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>
```

- ハニーポットは画面外に置き、タブ順から外し、スクリーンリーダーからも隠します。各属性の説明は[ハニーポットのガイド](https://ikiform.com/ja/guides/spam-protection/honeypot.md) にあります。
- `_redirect` は訪問者をサンクスページに移動させます。Ikiform がこれに従うのは、フォームの **Allowed domains**（許可ドメイン）にある `https:` のアドレスの場合だけです。
- セットアップ、ショートコード、GitHub Pages については [Hugo](https://ikiform.com/ja/docs/guides/hugo.md)、[Jekyll](https://ikiform.com/ja/docs/guides/jekyll.md)、[Eleventy](https://ikiform.com/ja/docs/guides/eleventy.md) のガイドで説明しています。

## 壊さずに機能を上乗せする

スクリプトで訪問者をページにとどめることはできますが、スクリプトがフォームを動かす唯一の手段になってはいけません。このスクリプトは `fetch` で送信し、2 つの場合に通常のブラウザー送信にフォールバックします。ネットワーク呼び出しが失敗したときと、Ikiform の人間確認が `challenge_required` でトークンを求めたときです。通常の送信では代わりに Ikiform の確認ページが表示されるので、スクリプトを変えずに確認をオンにできます。

`contact-form.html`:

```html
<script>
  document.querySelectorAll("form.contact").forEach((form) => {
    const status = form.querySelector("[role=status]");
    const button = form.querySelector("button");

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      try {
        const response = await fetch(form.action, {
          method: "POST",
          headers: { Accept: "application/json" },
          body: new FormData(form),
        });
        const result = await response.json();
        if (result.ok) {
          form.replaceChildren("Thanks. We'll reply by email.");
          return;
        }
        if (result.error.code === "challenge_required") {
          form.submit();
          return;
        }
        const wait = response.headers.get("Retry-After");
        if (response.status === 429 && wait) {
          status.textContent = `Too many messages. Try again in ${wait} seconds.`;
        } else {
          status.textContent = result.error.message;
        }
      } catch {
        form.submit();
        return;
      }
      button.disabled = false;
    });
  });
</script>
```

- `form.submit()` は `submit` イベントを再び発火させずにフォームを送信するので、フォールバックがループすることはありません。
- `429` のときは、Ikiform がブラウザーのコードに公開している `Retry-After` から待ち時間を表示します。
- 成功の一文がフォームを置き換えるので、うっかり 2 回送信されることはありません。

## ホストの関数を書く場合

Netlify、Cloudflare Pages、Vercel はどれも、静的ファイルの隣で小さな関数を実行できます。関数は自分のバックエンドなので、スパムチェックも関数が担います。この Netlify Function は、ハニーポットを読み、リンクだらけのメッセージを捨て、Turnstile のトークンを検証し、Resend 経由でメールを送ります。捕まえた送信にも、本物と同じサンクスページへのリダイレクトを返します。

`netlify/functions/contact.mts`:

```ts
import type { Config, Context } from "@netlify/functions";

const LINK = /https?:\/\/|www\./gi;

export default async (req: Request, context: Context) => {
  if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
  const form = await req.formData();
  const email = String(form.get("email") ?? "");
  const message = String(form.get("message") ?? "");
  const thanks = Response.redirect(new URL("/thanks/", req.url), 303);

  if (String(form.get("_gotcha") ?? "").trim()) return thanks;
  if ((message.match(LINK)?.length ?? 0) >= 3) return thanks;

  const check = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    body: new URLSearchParams({
      secret: process.env.TURNSTILE_SECRET_KEY ?? "",
      response: String(form.get("cf-turnstile-response") ?? ""),
      remoteip: context.ip,
    }),
  });
  const verdict = (await check.json()) as { success: boolean };
  if (!verdict.success) return Response.redirect(new URL("/contact/?error=check", req.url), 303);

  await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      from: "Contact form <form@example.com>",
      to: "you@example.com",
      reply_to: email,
      subject: "New message from the website",
      text: message,
    }),
  });
  return thanks;
};

export const config: Config = { path: "/api/contact" };
```

- `context.ip` は訪問者の IP で、siteverify の `remoteip` として使えます。
- `TURNSTILE_SECRET_KEY` と `RESEND_API_KEY` はサイトの環境変数に設定し、リポジトリには絶対に入れないでください。
- 捕まえた送信は保存せずに捨てるので、誤って捕まった本物のメッセージは失われます。少なくともログには残してください。
- 関数は呼び出しの間で記憶を持たないので、レート制限はありません。Redis で追加する方法は[レート制限のガイド](https://ikiform.com/ja/guides/spam-protection/rate-limiting.md)にあります。

## 静的なフォームに対して Ikiform が行うこと

フォームが Ikiform に直接送信する場合、サーバー側の処理は Ikiform で行われます。ハニーポットは `_gotcha` で、内容のスコア判定と訪問者ごとのレート制限はすべての送信で実行され、**Allowed domains** は他のサイトからの送信をスパムとして扱います。**Spam check** を **Ikiform** にすると、キーなしで Cloudflare Turnstile を使えます。プレーンな HTML の送信では確認ページで実行され、上のスクリプトはそのページにフォールバックします。スパムと判定された送信にも本物と同じ成功の応答を返すので、ボットは何も学べません。ステータス `spam` で保存され、通知メールも Webhook も送らず、使用量にもカウントされず、30 日後に削除されます。毎週月曜日、スパムをブロックしたワークスペースには、合計件数、最も多かったフォーム、主な理由をまとめたメールが 1 通届きます。

> フォームを公開しているすべてのホストを **Allowed domains** に登録します。自分のドメインに加え、テスト中は `username.github.io` のようなホスト自身のアドレスも登録してください。Netlify のデプロイプレビューや Vercel のプレビュー URL はメインサイトのアドレスのサブドメインではないので、プレビューをテストするときはそのホストを正確に追加します。

## 動作を確認する

1. JavaScript をオフにして送信します。フォームは送信され、サンクスページに移動します。
2. JavaScript をオンにして送信します。フォームがお礼の一文に置き換わります。
3. ハニーポットを `_gotcha=bot` と埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
4. **Spam check** を **Ikiform** にし、スクリプトをオンにして送信します。ページは通常の送信にフォールバックし、確認ページを表示します。
5. 何も届きませんか？ダッシュボードのステータスフィルターを **Spam** に切り替えてください。スパムにも本物の送信データと同じ成功の応答が返ります。

## ハマりどころ

- **`403 origin_not_allowed` が返る、またはすべての送信がスパムになる。** ページのドメインがフォームの **Allowed domains** にありません。ドメインと、ローカルテスト用の `localhost` を追加してください。登録したドメインのサブドメインは許可されます。
- **HTML の圧縮。** ビルド処理によっては、空や未使用とみなした属性を削除します。ビルド後のページで、ハニーポットに `aria-hidden` と `tabindex` が残っているか確認してください。
- **キャッシュされたサンクスページ。** サンクスページはスパムにも本物の送信にも同じ URL であるべきなので、送信が本物であることを前提にした内容を載せてはいけません。
- **リポジトリ内のキー。** 公開されている静的サイトのリポジトリにコミットしたシークレットは公開されています。一度でもコミットしたならローテーションしてください。

## よくある質問

### 静的サイトに captcha を追加できますか？

ウィジェットは表示できますが、トークンはサーバーでシークレットキーを使って検証する必要があります。ホストの関数、キーを検証してくれるフォームバックエンド、キー不要の Ikiform のマネージドな確認のいずれかを使ってください。

### 静的サイトならハニーポットだけで十分ですか？

よくあるボットは止められますが、それはサーバー上の何かがハニーポットを読む場合に限ります。レート制限と内容のスコア判定を組み合わせてください。これらにもサーバーかフォームバックエンドが必要です。

### 静的サイトのフォームで 405 Method Not Allowed が表示されるのはなぜですか？

ファイルを配信するだけのホスト上のページにフォームが送信しているからです。フォームの action を関数かフォームバックエンドのエンドポイントに向けてください。

### JavaScript がオフでもフォームは動きますか？

動くべきです。まずプレーンな HTML フォームで送信し、それを改善するスクリプトを追加して、何か問題があれば通常の送信にフォールバックさせます。

## その他のガイド

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