# Turnstile を使った Astro フォームのスパム対策

> Action で Astro のフォームをスパムから守ります。ハニーポット、astro:env のシークレットで確認する Turnstile、IP ごとの制限、Astro 組み込みの CSP。

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

チェックは、フォームが JavaScript なしで送信する Astro Action に置きます。`context.clientAddress` で制限をかけ、`astro:env/server` のシークレットで Turnstile のトークンを検証してから、ハニーポットとともにフィールドを転送します。お問い合わせページはオンデマンドでレンダリングする必要があるので、アダプターを追加し、そのページに `prerender = false` を設定します。サイト全体を静的なままにしたいなら、通常のフォームを Ikiform に送信し、同じチェックを Ikiform に任せてください。

## Astro ならではの違い

Astro はデフォルトで静的 HTML をビルドしますが、静的 HTML は秘密を守れません。Turnstile のシークレット、Ikiform のシークレットキー、レート制限のカウンターはどれもリクエストごとに動くコードを必要とするので、Astro サイトで最初に決めるのは、お問い合わせページをそもそもオンデマンドでレンダリングするかどうかです。

図: Astro サイトでスパムチェックを動かす場所を選ぶ

1. お問い合わせページのレンダリング方法を決めます。
2. 自分の Turnstile キーを使う場合、ページはアダプター経由でオンデマンドにレンダリングします。
3. フォームは Astro Action に送信し、Action が IP で制限をかけ、トークンを検証し、フィールドを転送します。
4. Ikiform がメッセージを保存し、ハニーポットをスコア判定します。
5. サイトを静的なままにするなら、通常のフォームが Ikiform に直接送信し、Ikiform がハニーポット、マネージドの Turnstile チェック、レート制限を実行します。

どちらの方法でも止められるボットは同じです。上の方法では自分のウィジェットと自分の制限を使え、下の方法ではサーバーのコードを一切出荷しません。

Astro はデフォルトで、`security.checkOrigin` によりオンデマンドのページへのフォーム送信のオリジンも確認します。これで他のサイトのページからあなたのサイトへの送信は止まりますが、好きなヘッダーを送れるスクリプトは止まらないので、以下のチェックは依然として必要です。

## プロジェクトの準備

`ファイル`:

```text
src/
  actions/
    index.ts
  lib/
    limit.ts
    turnstile.ts
  pages/
    contact.astro
astro.config.mjs
.env
```

`npx astro add vercel`、`netlify`、`cloudflare` でアダプターを追加します。すべての変数を `astro:env` のスキーマで宣言すれば、シークレットがないとインポート時に失敗し、シークレットがクライアントのコードにインポートされることもありません:

`.env`:

```bash
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_token
```

`astro.config.mjs`:

```js
import vercel from "@astrojs/vercel";
import { defineConfig, envField } from "astro/config";

export default defineConfig({
  adapter: vercel(),
  env: {
    schema: {
      PUBLIC_TURNSTILE_SITE_KEY: envField.string({ context: "client", access: "public" }),
      TURNSTILE_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      UPSTASH_REDIS_REST_URL: envField.string({ context: "server", access: "secret" }),
      UPSTASH_REDIS_REST_TOKEN: envField.string({ context: "server", access: "secret" }),
    },
  },
  security: {
    csp: {
      scriptDirective: {
        resources: ["'self'", "https://challenges.cloudflare.com"],
      },
      directives: ["frame-src https://challenges.cloudflare.com"],
    },
  },
});
```

- `access: "secret"` を付けた値はバンドルに含まれません。`astro:env/server` はクライアントのスクリプトでは読み込めません。
- `scriptDirective.resources` は Astro のデフォルトを置き換えるので、Cloudflare と並べて `'self'` も改めて記載します。Astro 自身のスクリプトは引き続きハッシュ化されます。
- CSP は `astro dev` では動きません。`astro build` と `astro preview` で確認してください。

## IP ごとの制限

Action の中では、`context.clientAddress` はアダプターがプラットフォームから取得した訪問者の IP なので、`X-Forwarded-For` を自分で解析する必要はありません。これはオンデマンドのルートにしか存在せず、プリレンダーしたルートでは例外を投げます。このカウンターは IP ごと 1 分ごとに Redis のキーを 1 つ持つので、複数のサーバーレスインスタンスをまたいで動きます:

`src/lib/limit.ts`:

```ts
import { Redis } from "@upstash/redis";
import { UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "astro:env/server";

const redis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });

export async function overLimit(ip: string, limit = 5, windowSeconds = 60) {
  const key = `contact:${ip}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
  const [count] = await redis.multi().incr(key).expire(key, windowSeconds).exec<[number, number]>();
  return count > limit;
}
```

Cloudflare では、Astro 7 は `Astro.locals.runtime` ではなく `cloudflare:workers` からバインディングを読みます。`wrangler.jsonc` で宣言した Workers のレート制限バインディングなら、ネットワーク呼び出しなしで Redis の代わりになりますが、カウントはロケーションごとの概算になります:

`src/lib/limit.ts`:

```ts
import { env } from "cloudflare:workers";

export async function overLimit(ip: string) {
  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  return !success;
}
```

## トークンを検証する

`src/lib/turnstile.ts`:

```ts
import { TURNSTILE_SECRET_KEY } from "astro:env/server";

export async function isHuman(token: string, ip: string, host: string) {
  const body = new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip });
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    if (!verdict.success || verdict.action !== "contact") return false;
    return import.meta.env.DEV || verdict.hostname === host;
  } catch {
    return false;
  }
}
```

- siteverify はフォームエンコードのボディを受け付けるので、`URLSearchParams` にヘッダーは要りません。
- ホスト名は、訪問者が実際に送信したホストである `context.url.hostname` と比較します。テスト用トークンにはダミーのホスト名が入るので、`astro dev` ではこのチェックをスキップします。
- トークンの有効期間は 300 秒で、使えるのは 1 回だけです。通常のフォーム送信ではページが再読み込みされ、そのたびに Turnstile が新しいトークンを発行します。

## Action

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
import { overLimit } from "../lib/limit";
import { isHuman } from "../lib/turnstile";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1, "Tell us your name.").max(120),
      email: z.email("Enter an address we can reply to."),
      message: z.string().trim().min(10, "A few words more, please.").max(5000),
      _gotcha: z.string().optional(),
      "cf-turnstile-response": z.string().max(2048).optional(),
    }),
    handler: async (input, context) => {
      const ip = context.clientAddress;
      if (await overLimit(ip)) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Too many messages. Try again in a minute." });
      }
      if (!(await isHuman(input["cf-turnstile-response"] ?? "", ip, context.url.hostname))) {
        throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try again." });
      }

      const { "cf-turnstile-response": _token, ...fields } = input;
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(fields),
      });
      if (!response.ok) throw new ActionError({ code: "BAD_GATEWAY", message: "Your message didn't send. Try again." });
      return { sent: true };
    },
  }),
};
```

- Zod はハンドラーの前に実行されるので、形式の崩れた送信で Redis を呼ぶことはありません。フィールドエラーは `isInputError` でページに返ります。
- ハニーポット `_gotcha` はスキーマ上は任意で、そのまま転送されます。埋められていれば Ikiform がスパムと判定するので、あとから確認できます。
- トークンは転送前に取り除きます。検証済みのトークンは Ikiform には不要です。
- サーバー側の fetch は `Origin` を送らないので、訪問者のものを一緒に渡します。Ikiform はこれを読んで無料の `localhost` テストを判別します。
- `TOO_MANY_REQUESTS` は `429` に、`FORBIDDEN` は `403` に対応します。

> サーバー側の版はシークレットキーで送信するので、Ikiform は **Allowed domains** と自身の人間確認をスキップします。すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分 5 件という Standard の制限は全訪問者の合計で数えられます。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

## ページ

`src/pages/contact.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";
import { PUBLIC_TURNSTILE_SITE_KEY } from "astro:env/client";

export const prerender = false;

const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---

<main>
  <h1>Contact us</h1>
  {result?.data?.sent ? (
    <p role="status">Thanks. We'll reply by email.</p>
  ) : (
    <form method="POST" action={actions.sendMessage}>
      <label for="name">Name</label>
      <input id="name" name="name" autocomplete="name" required maxlength="120" />
      {fields.name && <p class="field-error">{fields.name[0]}</p>}
      <label for="email">Email</label>
      <input id="email" name="email" type="email" autocomplete="email" required />
      {fields.email && <p class="field-error">{fields.email[0]}</p>}
      <label for="message">Message</label>
      <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
      {fields.message && <p class="field-error">{fields.message[0]}</p>}
      <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>
      <div class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
      {result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
      <button>Send message</button>
    </form>
  )}
</main>

<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

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

フォームはクライアントの JavaScript なしで動きます。`action={actions.sendMessage}` はクエリ文字列にアクション名を付けて同じページに送信し、Astro がアクションを実行して、レンダリング中に `Astro.getActionResult()` が結果を読みます。オンデマンドでレンダリングするのはこのページだけで、サイトの残りは静的なままです。

- 送信に失敗した後に再読み込みすると、フォームの再送信を求められます。それが気になるなら、Astro のドキュメントは、結果をセッションで運ぶ POST、リダイレクト、GET のパターンを勧めています。
- Turnstile のスクリプトには `is:inline` を付けているので、Astro は手を加えません。Cloudflare 自身の URL から読み込む必要があります。
- 静的なページのクライアントスクリプトからアクションを呼ぶこともできますが、その場合は試行のたびに自分で `turnstile.reset()` を呼ぶ必要があります。

## デプロイ

| ホスト | アダプター | シークレットの置き場所 | 制限 |
| --- | --- | --- | --- |
| Vercel | `@astrojs/vercel` | プロジェクトの環境変数 | Redis |
| Netlify | `@astrojs/netlify` | サイトの環境変数 | Redis |
| Cloudflare | `@astrojs/cloudflare` | `wrangler secret put` | Workers のレート制限バインディング |

デプロイ前に本番のホスト名を Turnstile のウィジェットに追加し、プレビューでテストするならそのホスト名も追加してください。

## 静的なままにして Ikiform にチェックさせる

Astro サイトの多くは静的で、お問い合わせフォームのためだけにアダプターを追加するのは無視できないコストです。アダプターなしなら、フォームを Ikiform のエンドポイントに直接送信します:

`src/components/Contact.astro`:

```astro
---
---

<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" rows="5" required minlength="10"></textarea>
  <div class="hp" aria-hidden="true">
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
  </div>
  <button>Send message</button>
</form>
```

フォームの **Spam check** を **Ikiform** にします。トークンのない送信には ikiform.com 上の短い確認ページが表示され、Turnstile を実行してフィールドを送り届けるので、ページにはスクリプトもキーも CSP の変更も要りません。**Rate limit** は Strict（訪問者ごとに 10 分あたり 2 件）を選び、**Allowed domains** に自分のドメインを追加します。スパムには本物のメッセージと同じ応答が返り、誤判定を救えるよう 30 日間保持されます。残りの手順は [Astro のセットアップガイド](https://ikiform.com/ja/docs/guides/astro.md)にあります。

## 動作を確認する

1. テスト用キーで `astro dev` を実行し、メッセージを送ります。**Submissions** に表示されます。プランのないワークスペースでも、`localhost` からのテストを月 50 件まで受け付けます。
2. devtools で `required` 属性を外し、名前を空のままにします。入力欄の下にフィールドエラーが表示されます。
3. `TURNSTILE_SECRET_KEY` を常に失敗するシークレットにして送信します。ページに人間確認のエラーが表示されます。
4. 1 分間に 6 回送信します。6 回目は制限のメッセージ付きで `429` を返します。
5. ハニーポットを `_gotcha=bot` と埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
6. `astro build` と `astro preview` を実行してページを開き、コンソールに CSP 違反が出ていないことを確認します。

## ハマりどころ

| 問題 | 対処 |
| --- | --- |
| プリレンダーしたページで `clientAddress` が使えない | ページかアクションがビルド時に実行されています。お問い合わせページに `export const prerender = false` を追加してください。 |
| アクションの結果が常に undefined になる | フォームがプリレンダーしたページに送信しています。フォームのアクションは、オンデマンドでレンダリングするページでしか動きません。 |
| 開発環境では問題ないのに、本番でウィジェットが空になる | CSP がブロックしています。`scriptDirective.resources` に `https://challenges.cloudflare.com` を記載し、`frame-src` でも許可する必要があります。 |
| カスタムドメインからのすべての送信で、メッセージなしの `403` が返る | `security.checkOrigin` が別のオリジンを検出しています。よくあるのはプロキシがホストを書き換えている場合です。Astro に届く host ヘッダーがページのドメインと一致しているか確認してください。 |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムは何も送りません。**Spam** フィルターで開いて理由を確認し、スパムではないとマークすればメールと Webhook が送られます。 |

## よくある質問

### Astro Actions は静的サイトで動きますか？

フォーム Action としては動きません。Action に送信するフォームには、アダプター経由でオンデマンドにレンダリングされるページが必要です。完全に静的なサイトなら、代わりに Ikiform などのフォームバックエンドにフォームを送信してください。

### Astro で訪問者の IP を取得するには？

Action では context.clientAddress を、ページでは Astro.clientAddress を使います。アダプターがプラットフォームから値を埋めます。オンデマンドでレンダリングされるルートにしか存在しません。

### Astro の CSP は Turnstile に対応していますか？

はい。Astro 6 で安定版になった security.csp を使います。scriptDirective.resources の 'self' の隣に https://challenges.cloudflare.com を追加し、同じホストの frame-src ディレクティブも追加します。開発環境では動かないので、本番ビルドでテストしてください。

### Ikiform は無料ですか？

いいえ、無料プランはありません。テストは無料で、支払い前でも localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 を購入できます。クレジットに有効期限はありません。

## その他のガイド

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