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

> SvelteKit のフォームをスパムから守る方法。getClientAddress を使うフォーム Action、IP ごとの制限、use:enhance の裏の Turnstile、それを許可する CSP。

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

チェックはフォーム Action で行います。`getClientAddress()` で制限し、siteverify で Turnstile のトークンを検証してから、ハニーポットと一緒にフィールドを転送します。トークンは 1 回しか使えないので、ウィジェットは Svelte のアクションで描画し、`use:enhance` のコールバックでリセットします。`sveltekit()` プラグインの `csp` オプションで `challenges.cloudflare.com` を許可し、`adapter-node` では本当の IP がどのプロキシヘッダーにあるかを SvelteKit に伝えます。

## SvelteKit ならではの違い

SvelteKit は訪問者の IP を直接渡してくれます。リクエストイベントの `getClientAddress()` です。この値の出どころはアダプターによって異なり、`adapter-node` のデフォルトはプロキシの背後では誤った値になります。そこさえ正しくすれば、残りのチェックはフォーム Action にきれいに収まります。

図: アダプターごとに getClientAddress() が訪問者の IP をどこから取得するか

1. フォーム Action がリクエストイベントにクライアントのアドレスを尋ねます。
2. SvelteKit は、デプロイに使うアダプターに答えを委ねます。
3. adapter-node は、ADDRESS_HEADER と XFF_DEPTH を設定しない限りソケットのリモートアドレスを使います。
4. Vercel、Netlify、Cloudflare のアダプターはプラットフォームが渡すアドレスを読むので、設定は不要です。

nginx やロードバランサーの背後では、ADDRESS_HEADER を設定するまで adapter-node にはすべての訪問者がプロキシのアドレスに見えます。共有された 1 つのアドレスのせいで、IP ごとの制限がサイト全体の制限になってしまいます。

SvelteKit のもう 1 つの細かい点は `use:enhance` です。ページを再読み込みせずに送信するので利用者には快適ですが、Turnstile が自動で再描画されることはありません。応答のたびに手動でウィジェットをリセットする必要があります。

## プロジェクトの準備

`ファイル`:

```text
src/
  env.ts
  lib/
    turnstile.ts
    server/
      spam.ts
  routes/
    contact/
      +page.server.ts
      +page.svelte
vite.config.ts
.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
```

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  PUBLIC_TURNSTILE_SITE_KEY: { public: true },
  TURNSTILE_SECRET_KEY: {},
  IKIFORM_SECRET_KEY: {},
  UPSTASH_REDIS_REST_URL: {},
  UPSTASH_REDIS_REST_TOKEN: {},
});
```

- SvelteKit 3 は `src/env.ts` で宣言した変数を読みます。`public: true` を持つのはサイトキーだけなので、`$app/env/public` がブラウザーに渡せるのはそれだけです。`src/lib/server` など `server` ディレクトリ内のものはクライアントのコードからは一切インポートできず、試みると SvelteKit がビルドを失敗させます。
- 宣言した変数は、`static: true` を設定しない限りアプリの起動時に読まれるので、キーを更新したら再ビルドではなく再起動が必要です。`$env/*` モジュールも動きますが、SvelteKit 3 では非推奨です。
- Cloudflare のテスト用キーは `localhost` を含むどのホストでも通ります。

`vite.config.ts`:

```ts
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    sveltekit({
      adapter: adapter(),
      csp: {
        mode: "auto",
        directives: {
          "script-src": ["self", "https://challenges.cloudflare.com"],
          "frame-src": ["https://challenges.cloudflare.com"],
          "object-src": ["none"],
          "base-uri": ["self"],
        },
      },
    }),
  ],
});
```

SvelteKit 3 のオプションは `vite.config.ts` で `sveltekit()` プラグインに渡します。`svelte.config.js` はもう読まれません。`mode: "auto"` にすると、SvelteKit はサーバーレンダリングのページでは自身のインラインスクリプトに nonce を、プリレンダーのページではハッシュを付けるので、ポリシーに `'unsafe-inline'` は不要です。Turnstile は `script-src` で許可したホストから読み込まれ、`frame-src` で許可した iframe を描画します。

## サーバー専用のヘルパー

`src/lib/server/spam.ts`:

```ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { dev } from "$app/env";
import { TURNSTILE_SECRET_KEY, UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "$app/env/private";

const limiter = new Ratelimit({
  redis: new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }),
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  prefix: "contact",
});

export async function secondsToWait(ip: string) {
  const { success, reset } = await limiter.limit(ip);
  return success ? 0 : Math.max(1, Math.ceil((reset - Date.now()) / 1000));
}

export async function isHuman(token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
    });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (dev || verdict.hostname === host);
  } catch {
    return false;
  }
}
```

- 制限は Redis 上のスライディングウィンドウで、すべてのサーバーインスタンスで共有され、IP ごとに 1 分あたり 5 通です。`reset` はミリ秒で返るので、待ち時間の秒数に変換します。
- `$app/env` の `dev` を使い、ローカルではホスト名のチェックをスキップします。テスト用のトークンにはダミーのホスト名が入っているためです。
- siteverify は、300 秒を過ぎたトークンや使用済みのトークンに `timeout-or-duplicate` を返します。だからページでウィジェットをリセットするのです。

## フォーム Action

`src/routes/contact/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { isHuman, secondsToWait } from "#lib/server/spam.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress, url }) => {
    const ip = getClientAddress();
    const wait = await secondsToWait(ip);
    if (wait) return fail(429, { wait, error: `Too many messages. Try again in ${wait} seconds.` });

    const form = await request.formData();
    const values = {
      name: String(form.get("name") ?? "").trim(),
      email: String(form.get("email") ?? "").trim(),
      message: String(form.get("message") ?? "").trim(),
    };
    if (values.message.length < 10) return fail(400, { values, error: "A few words more, please." });
    if (!(await isHuman(String(form.get("cf-turnstile-response") ?? ""), ip, url.hostname))) {
      return fail(403, { values, error: "We couldn't confirm you're human. Try again." });
    }

    form.delete("cf-turnstile-response");
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: form,
    }).catch(() => null);
    if (!response?.ok) return fail(502, { values, error: "Your message didn't send. Try again." });
    return { sent: true };
  },
} satisfies Actions;
```

- 制限はボディの解析より前に実行されます。`fail(429)` なら訪問者はメッセージを見ながらページにとどまります。
- それ以外の失敗ではすべて `values` を返すので、エラーの後も入力欄に値が戻ります。
- 元の `FormData` を、トークンを除いてハニーポットごと転送します。ハニーポットが埋まっていれば Ikiform が評価し、メッセージをスパムとして保存します。
- 訪問者の `Origin` を渡します。サーバー側の fetch はこれを送らず、Ikiform は無料の `localhost` テストを判定するためにこれを読むからです。

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

## Svelte のアクションとしてのウィジェット

`src/lib/turnstile.ts`:

```ts
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

interface Api {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

let loading: Promise<Api> | undefined;

function load() {
  loading ??= new Promise<Api>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = SCRIPT;
    script.async = true;
    script.onload = () => {
      const api = (window as Window & { turnstile?: Api }).turnstile;
      if (api) resolve(api);
      else reject(new Error("Turnstile did not load"));
    };
    script.onerror = () => reject(new Error("Turnstile did not load"));
    document.head.append(script);
  });
  return loading;
}

export function turnstile(node: HTMLElement, sitekey: string) {
  let api: Api | undefined;
  load().then((loaded) => {
    api = loaded;
    node.dataset.widgetId = loaded.render(node, { sitekey, action: "contact" });
  });
  return {
    destroy() {
      if (node.dataset.widgetId) api?.remove(node.dataset.widgetId);
    },
  };
}

export async function resetTurnstile(node: HTMLElement | undefined) {
  const id = node?.dataset.widgetId;
  if (id) (await load()).reset(id);
}
```

サードパーティのウィジェットの置き場所としては Svelte のアクションが自然です。要素のマウント時に実行され、要素がなくなるときに `destroy` が実行されます。ウィジェット ID は要素に保持するので、ページはその特定のウィジェットをリセットできます。

## ページ

`src/routes/contact/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
  import { resetTurnstile, turnstile } from "#lib/turnstile.js";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let box = $state<HTMLDivElement>();
  let sending = $state(false);
</script>

{#if form?.sent}
  <p role="status">Thanks. We'll reply by email.</p>
{:else}
  <form
    method="POST"
    use:enhance={() => {
      sending = true;
      return async ({ update }) => {
        await update({ reset: false });
        sending = false;
        await resetTurnstile(box);
      };
    }}
  >
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required value={form?.values?.message ?? ""}></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>
    <div bind:this={box} use:turnstile={PUBLIC_TURNSTILE_SITE_KEY}></div>
    {#if form?.error}
      <p role="alert">{form.error}</p>
    {/if}
    <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
  </form>
{/if}
```

- `update({ reset: false })` は結果を反映し、訪問者が入力した内容を残します。デフォルトでは、ブラウザーと同じようにフォームがクリアされます。
- `resetTurnstile` は、成功でも失敗でも応答のたびに実行します。使用済みのトークンは消え、新しいトークンが用意されます。
- JavaScript がなくてもフォームは送信され Action も実行されますが、ウィジェットが描画されないので、siteverify がわかりやすいメッセージで拒否します。
- フォーム Action のあるページはプリレンダーできないので、このルートは常にサーバーでレンダリングされます。他のページはプリレンダーのままで構いません。
- `.hp` クラスは、[ハニーポットのガイド](https://ikiform.com/ja/guides/spam-protection/honeypot.md) と同じようにハニーポットを画面外に隠します。

## ローカルでテストする

`vite dev` ではアプリの前にアダプターがないので、`getClientAddress()` はループバックアドレスを返し、ローカルのタブはすべて 1 つのバケットを共有します。メッセージの確認には問題ありませんが、プロキシ設定の確認にはなりません。それを確認するには、`adapter-node` でビルドし、本番と同じ変数で出力を実行します:

`ターミナル`:

```bash
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node build
```

異なる `X-Forwarded-For` の値で何度か送信します。`XFF_DEPTH=1` なら SvelteKit は一番右のアドレス、つまりプロキシが追加するアドレスを読むので、クライアントが左側にでっち上げた値は何も変えません。

## デプロイ

自前のサーバーで 1 つのプロキシの背後に `adapter-node` を置く場合は、本当の IP がどこにあるかを SvelteKit に伝えます:

`環境`:

```bash
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1
```

SvelteKit のドキュメントは、ヘッダーは簡単に偽装でき、`X-Forwarded-For` の一番左のアドレスを読むのが典型的な間違いだと警告しています。`XFF_DEPTH` は信頼するプロキシの数を右から数えるので、運用しているプロキシの数に設定し、プロキシを経由せずにアプリへ到達できないようにしてください。SvelteKit 3 では adapter-node の `ORIGIN` 変数が廃止されました。プロキシが元のホストを渡さない場合は、`sveltekit()` のオプションで `paths.origin` を設定し、SvelteKit 自身のフォームチェックが公開 URL を把握できるようにします。

| アダプター | クライアント IP | シークレット |
| --- | --- | --- |
| `adapter-node` | `ADDRESS_HEADER` と `XFF_DEPTH` | プロセスの環境変数 |
| `adapter-vercel` | 自動 | プロジェクトの環境変数 |
| `adapter-netlify` | 自動 | サイトの環境変数 |
| `adapter-cloudflare` | 自動 | `wrangler secret put` |

## チェックを Ikiform に任せる

これを一切自分で運用しないなら、`+page.server.ts` を削除し、`use:enhance` を使わずにフォームの `action` を Ikiform のエンドポイントに向けます。すると、そのページも静的サイトの他のページと同じようにプリレンダーできます。

フォームの **Spam check** を **Ikiform** に設定すると、トークンのない送信には ikiform.com 上で Turnstile を実行してフィールドを送り届ける短い確認ページが表示されます。**Rate limit** で訪問者ごとに 10 分あたり 2 回の Strict を選び、**Allowed domains** にドメインを登録します。Ikiform は捕まえたスパムを 30 日間保持するので、誤判定はクリック 1 回で復元できます。エンドポイントとキーについては [SvelteKit の設定ガイド](https://ikiform.com/ja/docs/guides/sveltekit.md)を参照してください。

## 動作を確認する

1. テスト用のキーで `vite dev` を実行し、メッセージを送ります。**Submissions** に表示されます。プランのないワークスペースでも、`localhost` からのテストを月 50 件まで受け付けます。
2. 再読み込みせずに 2 通目のメッセージを送ります。成功すれば、ウィジェットのリセットが機能しています。
3. 1 分以内に 6 通のメッセージを送ります。6 通目では、あと何秒待てばよいかが表示されます。
4. プロキシの背後で、2 つのネットワークから `getClientAddress()` をログに出します。プロキシのアドレスではなく、2 つの異なるアドレスが表示されます。
5. ハニーポットを `_gotcha=bot` と埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。

## ハマりどころ

| 問題 | 対処 |
| --- | --- |
| `adapter-node` ですべての訪問者がまとめてレート制限される | `getClientAddress()` がプロキシのアドレスを返しています。`ADDRESS_HEADER` と `XFF_DEPTH` を設定してください。 |
| 2 回目の送信が必ず人間確認で失敗する | ウィジェットが使用済みのトークンを保持しています。`use:enhance` のコールバックで、`update` の後に `resetTurnstile` を呼んでください。 |
| Action のあるページがプリレンダーされてビルドが失敗する | お問い合わせのルートがレイアウトから `prerender = true` を継承しています。Action のあるページはプリレンダーできないので、その `+page.server.ts` で `export const prerender = false` を設定してください。 |
| ウィジェットの領域が空で、コンソールに CSP 違反が出る | `vite.config.ts` の `csp.directives` で、`script-src` か `frame-src` に `https://challenges.cloudflare.com` がありません。 |

## よくある質問

### SvelteKit でクライアント IP を取得するには？

フォーム Action、load 関数、フックで、リクエストイベントの getClientAddress() を呼びます。値はアダプターが渡します。プロキシの背後の adapter-node では、ADDRESS_HEADER と XFF_DEPTH を設定しないとプロキシのアドレスが返ります。

### use:enhance を使うと 2 回目の送信で Turnstile が失敗するのはなぜですか？

use:enhance はページを再読み込みしないので、ウィジェットが使用済みのトークンを保持したままになります。応答のたびに enhance のコールバックでウィジェットをリセットしてください。

### SvelteKit の CSP は Turnstile と一緒に使えますか？

はい。csp.directives の script-src と frame-src に https://challenges.cloudflare.com を追加します。SvelteKit 3 では、これを vite.config.ts の sveltekit() プラグインのオプションで渡します。mode を auto にすると、SvelteKit が自身のスクリプトに nonce かハッシュを付けます。

### Ikiform は無料ですか？

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

## その他のガイド

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