[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"/api/social-links:{}":3,"/api/blogs/free-spam-protection-turnstile-honeypot:{}":9},{"links":4},[5],{"platform":6,"url":7,"icon":8},"note","https://note.com/morinoupa2020","simple-icons:note",{"post":10,"relatedPosts":40,"prevPost":116,"nextPost":117},{"id":11,"slug":12,"title":13,"date":14,"excerpt":15,"content":16,"thumbnail":17,"categories":18,"tags":23,"difficulty":36,"tldr":37,"readingTime":38,"relatedTech":39},112,"free-spam-protection-turnstile-honeypot","お問い合わせフォームのスパム対策を無料で実装する — Cloudflare Turnstile × ハニーポットの2層防御","2026-02-28T02:12:59","お問い合わせフォームのスパム対策、後回しにしていませんか？Cloudflare Turnstileとハニーポットを組み合わせた2層防御を完全無料で実装する方法を、本番環境でのハマりポイントも含めて解説します。","\u003Cp>Webサイトにお問い合わせフォームを設置したものの、スパム対策をしていない——そんな状態に心当たりはありませんか？\u003C/p>\n\u003Cp>ポートフォリオサイトや小規模なビジネスサイトでは、「まだアクセスも少ないし、後回しでいいだろう」と思いがちです。しかし、botはサイトの規模に関係なくフォームを見つけて攻撃してきます。対策なしの状態では、\u003Cstrong>大量のスパムメールが送信されるリスク\u003C/strong>を常に抱えていることになります。\u003C/p>\n\u003Cp>この記事では、\u003Cstrong>完全無料\u003C/strong>で導入できる2層のスパム対策——Cloudflare TurnstileとハニーポットをNuxtのお問い合わせフォームに実装する方法を、実際にハマったポイントも含めて解説します。\u003C/p>\n\u003Ch2>無料で使えるスパム対策の選択肢\u003C/h2>\n\u003Cp>フォームのスパム対策にはいくつかの方法があります。代表的なものを比較してみましょう。\u003C/p>\n\u003Ch3>Google reCAPTCHA v3\u003C/h3>\n\u003Cp>もっとも広く使われているCAPTCHAサービスです。無料で使えますが、\u003Cstrong>Googleにユーザーの行動データが送信される\u003C/strong>点がプライバシー面で気になるところ。また、2024年の料金体系変更により、無料枠は\u003Cstrong>月10,000リクエストまで\u003C/strong>に縮小されました。小規模サイトでも超える可能性がある数字です。\u003C/p>\n\u003Ch3>Cloudflare Turnstile\u003C/h3>\n\u003Cp>Cloudflareが提供する無料のCAPTCHA代替サービスです。\u003Cstrong>「Managed」モード\u003C/strong>では、低リスクと判断されたユーザーにはチャレンジを表示せず、透過的に検証を完了します。プライバシーに配慮されており、ユーザー体験を損なわないのが大きな特長です。\u003Cstrong>完全無料\u003C/strong>で利用できます。\u003C/p>\n\u003Ch3>ハニーポット\u003C/h3>\n\u003Cp>CSSで非表示にしたダミーのフォームフィールドを設置する手法です。人間には見えませんが、botはHTMLを解析してすべてのフィールドに入力するため、ここに値が入っていればbotと判定できます。実装がシンプルで、\u003Cstrong>外部サービスに依存しない\u003C/strong>のがメリットです。\u003C/p>\n\u003Ch3>今回の選択：Turnstile × ハニーポットの2層防御\u003C/h3>\n\u003Cp>それぞれ単体でも効果はありますが、組み合わせることで\u003Cstrong>死角をなくす\u003C/strong>ことができます。\u003C/p>\n\u003Cul>\n\u003Cli>\u003Cstrong>ハニーポット\u003C/strong>で単純なbotをブロック（第1層）\u003C/li>\n\u003Cli>\u003Cstrong>Turnstile\u003C/strong>でハニーポットを回避する高度なbotもブロック（第2層）\u003C/li>\n\u003C/ul>\n\u003Cp>しかも\u003Cstrong>どちらも無料\u003C/strong>。コストをかけずにしっかりとしたスパム対策が実現できます。\u003C/p>\n\u003Ch2>2層防御の設計思想\u003C/h2>\n\u003Cp>今回の設計で重要にしたポイントは3つです。\u003C/p>\n\u003Ch3>1. botに検知を悟らせない\u003C/h3>\n\u003Cp>ハニーポットに引っかかったbotには、あえて\u003Cstrong>成功レスポンスを返します\u003C/strong>。「送信が拒否された」と分かるとbotの挙動が変わる可能性があるため、偽の成功を返して次の攻撃を防ぎます。\u003C/p>\n\u003Ch3>2. サーバーサイドで必ず検証する\u003C/h3>\n\u003Cp>Turnstileのトークン検証は\u003Cstrong>必ずサーバーサイドで行います\u003C/strong>。クライアント側のチェックだけではDevToolsで簡単にバイパスされてしまうためです。フロントエンドでの制御（ボタンの無効化など）はあくまでUX向上のためであり、セキュリティはサーバー側で担保します。\u003C/p>\n\u003Ch3>3. シークレットキーをクライアントに露出させない\u003C/h3>\n\u003Cp>Turnstileには「サイトキー（公開）」と「シークレットキー（秘密）」の2つがあります。サイトキーはブラウザに渡しても問題ありませんが、シークレットキーは\u003Cstrong>絶対にサーバー側にのみ配置\u003C/strong>します。Nuxtの\u003Ccode>runtimeConfig\u003C/code>（非public）を使うことで、クライアントバンドルに含まれないようにしています。\u003C/p>\n\u003Ch2>実装ガイド（Nuxt）\u003C/h2>\n\u003Ch3>Step 1: Cloudflare Turnstileのキーを取得する\u003C/h3>\n\u003Cp>まずは\u003Ca href=\"https://dash.cloudflare.com/sign-up\" target=\"_blank\" rel=\"noopener noreferrer\">Cloudflareのアカウント\u003C/a>を作成し（無料）、ダッシュボードから\u003Cstrong>Turnstileウィジェット\u003C/strong>を登録します。（以下は2026年3月時点の画面構成です。UIが変更されている場合は\u003Ca href=\"https://developers.cloudflare.com/turnstile/get-started/\" target=\"_blank\" rel=\"noopener noreferrer\">公式ドキュメント\u003C/a>を参照してください。）\u003C/p>\n\u003Col>\n\u003Cli>\u003Ca href=\"https://dash.cloudflare.com/\" target=\"_blank\" rel=\"noopener noreferrer\">Cloudflareダッシュボード\u003C/a>にログイン\u003C/li>\n\u003Cli>左メニューの「アプリケーションセキュリティ」内にある「Turnstile」を選択（\u003Ca href=\"https://dash.cloudflare.com/?to=/:account/turnstile\" target=\"_blank\" rel=\"noopener noreferrer\">直接リンク\u003C/a>）\u003C/li>\n\u003Cli>「ウィジェットを追加」をクリックし、ウィジェット名とサイトのドメインを登録\u003C/li>\n\u003Cli>ウィジェットモードは「Managed」を選択（推奨）\u003C/li>\n\u003Cli>作成後に表示される\u003Cstrong>サイトキー\u003C/strong>と\u003Cstrong>シークレットキー\u003C/strong>を控える\u003C/li>\n\u003C/ol>\n\u003Cp>サイトキーはフロントエンドに公開しても問題ないキー、シークレットキーはサーバーサイドでのみ使う秘密鍵です。取得したキーは環境変数（\u003Ccode>.env\u003C/code>）に設定しておきます。\u003C/p>\n\u003Cpre>\u003Ccode class=language-bash># frontend/.env\nNUXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAA...  # サイトキー\nNUXT_TURNSTILE_SECRET_KEY=0x4AAAA...        # シークレットキー\u003C/code>\u003C/pre>\n\u003Cp>詳細な手順は\u003Ca href=\"https://developers.cloudflare.com/turnstile/get-started/\" target=\"_blank\" rel=\"noopener noreferrer\">Cloudflare Turnstile公式ドキュメント\u003C/a>を参照してください。\u003C/p>\n\u003Ch3>Step 2: パッケージの導入\u003C/h3>\n\u003Cpre>\u003Ccode class=\"language-bash\">npm install -D @nuxtjs/turnstile\u003C/code>\u003C/pre>\n\u003Cp>\u003Ccode>nuxt.config.ts\u003C/code> にモジュールを登録します。\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-typescript\">// nuxt.config.ts\nexport default defineNuxtConfig({\n  modules: ['@nuxtjs/turnstile'],\n\n  turnstile: {\n    siteKey: process.env.NUXT_PUBLIC_TURNSTILE_SITE_KEY || '',\n  },\n\n  runtimeConfig: {\n    turnstile: {\n      secretKey: '', // NUXT_TURNSTILE_SECRET_KEY\n    },\n  },\n})\u003C/code>\u003C/pre>\n\u003Ch3>Step 3: ハニーポットの実装\u003C/h3>\n\u003Cp>フォームにCSSで完全に非表示にしたダミーフィールドを追加します。\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-html\">&lt;!-- ハニーポット: botのみが入力する隠しフィールド --&gt;\n&lt;div class=\"contact-form__hp\" aria-hidden=\"true\"&gt;\n  &lt;label for=\"website\"&gt;Website&lt;/label&gt;\n  &lt;input\n    id=\"website\"\n    v-model=\"formData.website\"\n    type=\"text\"\n    name=\"website\"\n    autocomplete=\"off\"\n    tabindex=\"-1\"\n  &gt;\n&lt;/div&gt;\u003C/code>\u003C/pre>\n\u003Cpre>\u003Ccode class=\"language-css\">.contact-form__hp {\n  position: absolute;\n  left: -9999px;\n  opacity: 0;\n  height: 0;\n  overflow: hidden;\n}\u003C/code>\u003C/pre>\n\u003Cp>ポイントは\u003Ccode>aria-hidden=\"true\"\u003C/code>と\u003Ccode>tabindex=\"-1\"\u003C/code>で、スクリーンリーダーやキーボード操作で到達しないようにすることです。\u003Ccode>display: none\u003C/code>ではなく位置をオフスクリーンにすることで、より多くのbotを騙せます。\u003C/p>\n\u003Ch3>Step 4: Turnstileウィジェットの配置\u003C/h3>\n\u003Cpre>\u003Ccode class=\"language-html\">&lt;div class=\"contact-form__turnstile\"&gt;\n  &lt;NuxtTurnstile v-model=\"turnstileToken\" /&gt;\n&lt;/div&gt;\n\n&lt;AppButton\n  :disabled=\"isSubmitting || !turnstileToken\"\n  @click=\"handleSubmit\"\n&gt;\n  {{ isSubmitting ? '送信中...' : '送信する' }}\n&lt;/AppButton&gt;\u003C/code>\u003C/pre>\n\u003Cp>Turnstileの検証が完了するまで送信ボタンを無効化しています。Managedモードでは、多くの場合ユーザーが気づかないうちに検証が完了します。\u003C/p>\n\u003Ch3>Step 5: サーバーサイド検証\u003C/h3>\n\u003Cp>ここが最も重要な部分です。サーバー側でCloudflareのAPIにトークンを送って検証します。\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-typescript\">// server/api/contact.post.ts\n\nexport default defineEventHandler(async (event) =&gt; {\n  const body = await readBody(event)\n\n  // 第1層: ハニーポットチェック\n  if (body.website) {\n    // botには成功を偽装して返す\n    return { success: true, message: 'お問い合わせを送信しました。' }\n  }\n\n  // 第2層: Turnstile トークン検証\n  const runtimeConfig = useRuntimeConfig(event)\n  const secret = runtimeConfig.turnstile?.secretKey\n    || process.env.NUXT_TURNSTILE_SECRET_KEY\n\n  const result = await $fetch(\n    'https://challenges.cloudflare.com/turnstile/v0/siteverify',\n    {\n      method: 'POST',\n      body: { secret, response: body.turnstileToken },\n    },\n  )\n\n  if (!result.success) {\n    throw createError({\n      statusCode: 400,\n      statusMessage: 'セキュリティ検証に失敗しました。',\n    })\n  }\n\n  // 検証通過 → メール送信処理へ\n  // ...\n})\u003C/code>\u003C/pre>\n\u003Ch2>実装・テストで苦労したポイント\u003C/h2>\n\u003Cp>ローカル開発環境では問題なく動作したものの、\u003Cstrong>本番環境に持っていくと次々と問題が発覚\u003C/strong>しました。原因の特定に時間がかかったポイントを共有します。\u003C/p>\n\u003Ch3>1. npmの依存関係の競合\u003C/h3>\n\u003Cp>最初の壁は\u003Ccode>npm install\u003C/code>でした。\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-bash\">npm error ERESOLVE could not resolve\nnpm error peer @nuxt/scripts@\"^0.11.0 || ^0.12.0\" from @nuxtjs/turnstile@1.1.1\nnpm error Found: @nuxt/scripts@0.13.2\u003C/code>\u003C/pre>\n\u003Cp>\u003Ccode>@nuxtjs/turnstile\u003C/code>が要求する\u003Ccode>@nuxt/scripts\u003C/code>のバージョンと、プロジェクトで使用しているバージョンが合わず、インストールが失敗しました。ローカルでは\u003Ccode>--legacy-peer-deps\u003C/code>オプションで回避できましたが、\u003Cstrong>CI/CDパイプラインの\u003Ccode>npm ci\u003C/code>でも同じエラー\u003C/strong>が発生。\u003C/p>\n\u003Cp>解決策は、\u003Ccode>.npmrc\u003C/code>ファイルをプロジェクトに追加することでした。\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-bash\"># frontend/.npmrc\nlegacy-peer-deps=true\u003C/code>\u003C/pre>\n\u003Cp>これにより、ローカルでもCI/CDでも一貫して依存関係の競合をスキップできるようになりました。\u003C/p>\n\u003Ch3>2. 本番環境でシークレットキーが読めない\u003C/h3>\n\u003Cp>デプロイ後、フォーム送信すると\u003Cstrong>400エラー\u003C/strong>。サーバーログを確認すると、\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-json\">{ \"error-codes\": [\"missing-input-secret\"], \"success\": false }\u003C/code>\u003C/pre>\n\u003Cp>Cloudflareから「シークレットキーがない」と返されていました。\u003Ccode>.env\u003C/code>にはちゃんとキーを設定していたのに、なぜ？\u003C/p>\n\u003Cp>原因は、\u003Cstrong>Nuxt（Nitro）の本番サーバーは\u003Ccode>.env\u003C/code>ファイルを自動で読み込まない\u003C/strong>という仕様でした。開発サーバー（\u003Ccode>nuxt dev\u003C/code>）ではViteが\u003Ccode>.env\u003C/code>を読み込んでくれますが、ビルド後の本番サーバーにはその仕組みがありません。\u003C/p>\n\u003Cp>このプロジェクトではPM2でプロセス管理をしており、\u003Ccode>ecosystem.config.cjs\u003C/code>で環境変数を定義していました。新しく追加したTurnstileのキーをこのファイルに追加し忘れていたのが原因です。\u003C/p>\n\u003Cp>さらに、サーバーサイドのコードも\u003Ccode>@nuxtjs/turnstile\u003C/code>モジュールが提供する\u003Ccode>verifyTurnstileToken\u003C/code>関数に依存していましたが、この関数が\u003Ccode>runtimeConfig\u003C/code>からキーを読み取る仕組みのため、環境変数が渡っていないとキーが空のままAPIを叩いてしまいます。最終的に、\u003Cstrong>Cloudflare APIを直接呼び出す実装に変更\u003C/strong>し、\u003Ccode>process.env\u003C/code>からの直接読み取りをフォールバックとして追加しました。\u003C/p>\n\u003Ch3>3. PM2の「restart」では環境変数が反映されない\u003C/h3>\n\u003Cp>\u003Ccode>ecosystem.config.cjs\u003C/code>にキーを追加した後、\u003Ccode>pm2 restart\u003C/code>したのに\u003Cstrong>まだ同じエラーが出る\u003C/strong>——これが一番時間を取られたポイントです。\u003C/p>\n\u003Cp>\u003Ccode>pm2 restart\u003C/code>は、既存のプロセスをそのまま再起動するだけで、\u003Cstrong>\u003Ccode>ecosystem.config.cjs\u003C/code>の変更は再読み込みしません\u003C/strong>。起動時にメモリに読み込んだ環境変数をそのまま使い続けるのです。\u003C/p>\n\u003Cp>正しい手順は以下の通りです。\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-bash\"># ❌ これでは ecosystem.config.cjs の変更は反映されない\npm2 restart morinoupa\n\n# ✅ 正しい方法: プロセスを削除して config を再読み込み\npm2 delete morinoupa\npm2 start ecosystem.config.cjs\npm2 save\u003C/code>\u003C/pre>\n\u003Cp>この挙動はPM2のドキュメントを読めば書いてありますが、いざ本番環境でトラブルシューティングしていると見落としがちです。\u003Cstrong>環境変数を変更したら、deleteしてからstartし直す\u003C/strong>——これを覚えておくだけで、同じ問題に遭遇したときに即座に解決できます。\u003C/p>\n\u003Ch3>教訓まとめ\u003C/h3>\n\u003Cul>\n\u003Cli>\u003Cstrong>ローカルで動いても本番で動くとは限らない\u003C/strong>——特に環境変数の読み込み方法はローカルと本番で異なる\u003C/li>\n\u003Cli>\u003Cstrong>\u003Ccode>pm2 restart\u003C/code> ≠ 設定の再読み込み\u003C/strong>——\u003Ccode>pm2 delete\u003C/code> + \u003Ccode>pm2 start\u003C/code>が正しい手順\u003C/li>\n\u003Cli>\u003Cstrong>外部モジュールの内部実装に依存しすぎない\u003C/strong>——ブラックボックスな関数よりも、自分で制御できるコードの方がデバッグしやすい\u003C/li>\n\u003Cli>\u003Cstrong>サーバーログは最強のデバッグツール\u003C/strong>——\u003Ccode>pm2 logs\u003C/code>でエラーの詳細を確認する習慣をつける\u003C/li>\n\u003C/ul>\n\u003Ch2>まとめ\u003C/h2>\n\u003Cp>お問い合わせフォームのスパム対策は、サイトの規模に関わらず必要なセキュリティ対策です。\u003C/p>\n\u003Cp>今回紹介したCloudflare Turnstile × ハニーポットの組み合わせなら、\u003C/p>\n\u003Cul>\n\u003Cli>\u003Cstrong>費用ゼロ\u003C/strong>で導入できる\u003C/li>\n\u003Cli>ユーザーにストレスを与えない（Managedモード）\u003C/li>\n\u003Cli>2層の防御で単純なbotから高度なbotまで対応\u003C/li>\n\u003Cli>サーバーサイド検証でバイパスを防止\u003C/li>\n\u003C/ul>\n\u003Cp>「まだアクセスが少ないから大丈夫」と思っていると、ある日突然スパムの洪水に見舞われるかもしれません。実装自体は1〜2時間程度で完了するので、\u003Cstrong>まずは一歩踏み出してみてください\u003C/strong>。無料でもここまでしっかりとした対策が取れます。\u003C/p>\n","",[19],{"id":20,"name":21,"slug":22},44,"技術深掘り","deep-dive",[24,28,32],{"id":25,"name":26,"slug":27},20,"Nuxt","nuxt",{"id":29,"name":30,"slug":31},26,"TypeScript","typescript",{"id":33,"name":34,"slug":35},80,"セキュリティ","security","intermediate","Cloudflare Turnstile（無料）とハニーポットの2層防御でお問い合わせフォームのスパム対策を実装。本番環境ではNitroが.envを読まない問題やPM2の環境変数反映に注意が必要。","7",[],[41,73,98],{"id":42,"slug":43,"title":44,"date":45,"excerpt":46,"thumbnail":17,"categories":47,"tags":49,"difficulty":70,"tldr":71,"readingTime":72},254,"ai-markup-horizontal-expansion","AIエージェントが一番確実に活躍できるフロントエンドの仕事 — マークアップの横展開","2026-03-25T19:01:28","AIにコードを書かせる場面は増えてきました。ただ、「結局どこに使うのが一番効くの？」という問いに対して、まだ手探りの方も多いんじゃないかと思います。 自分はフリーランスのフロントエンドエンジニアとして、日常的にAIエージ [&hellip;]",[48],{"id":20,"name":21,"slug":22},[50,54,58,62,66],{"id":51,"name":52,"slug":53},78,"AI協働","ai-collaboration",{"id":55,"name":56,"slug":57},81,"Claude Code","claude-code",{"id":59,"name":60,"slug":61},84,"フロントエンド","%e3%83%95%e3%83%ad%e3%83%b3%e3%83%88%e3%82%a8%e3%83%b3%e3%83%89",{"id":63,"name":64,"slug":65},85,"マークアップ","%e3%83%9e%e3%83%bc%e3%82%af%e3%82%a2%e3%83%83%e3%83%97",{"id":67,"name":68,"slug":69},86,"実務","%e5%ae%9f%e5%8b%99","beginner","フロントエンドの実務でAIエージェントが最も確実に力を発揮するのはマークアップの横展開。レイアウトパターンごとに数ページ人間が作り、残りをAIに任せると圧倒的に速い。完璧ではないが、単純作業の負担を大幅に減らせる。","6",{"id":74,"slug":75,"title":76,"date":77,"excerpt":78,"thumbnail":17,"categories":79,"tags":81,"difficulty":36,"tldr":96,"readingTime":97},252,"wordpress%e3%83%90%e3%83%83%e3%82%af%e3%82%a8%e3%83%b3%e3%83%89%e3%82%92ai%e3%81%ab%e5%85%a8%e4%bb%bb%e3%81%9b%e3%81%97%e3%81%a6%e3%81%bf%e3%81%9f-%e6%84%9f%e6%80%a7%e3%81%8c%e8%a6%81","WordPressバックエンドをAIに全任せしてみた — 感性が要らない領域こそAI向きなのか検証する","2026-03-15T02:38:53","WordPressのバックエンド——カスタム投稿タイプの定義、REST APIの設計、管理画面のカスタマイズ。こうした作業は、仕様が明確でパターン化しやすい。 前回までの連載で「デザインやアニメーションなど、人間の感性に [&hellip;]",[80],{"id":20,"name":21,"slug":22},[82,83,84,88,92],{"id":51,"name":52,"slug":53},{"id":55,"name":56,"slug":57},{"id":85,"name":86,"slug":87},82,"PHP","php",{"id":89,"name":90,"slug":91},83,"REST API","rest-api",{"id":93,"name":94,"slug":95},31,"WordPress","wordpress","ポートフォリオサイトのWordPressバックエンド（テーマ・REST API・管理画面）をAIに全任せで実装。定型的なバックエンド作業はAIの得意領域だが、実際に使い始めると「キー名の不一致」「運用を想定していない設計」など、細かい修正が必要になる場面があった。","8",{"id":99,"slug":100,"title":101,"date":102,"excerpt":103,"thumbnail":17,"categories":104,"tags":109,"difficulty":70,"tldr":115,"readingTime":72},246,"ai-web-dev-07-future-engineer","【AI×Web制作 #7】AI時代のエンジニア像 — これからの開発スタイル","2026-02-25T16:00:00","全7回の連載を振り返り、AI時代にエンジニアの価値はどう変わるかを考える。「使える人」から「引き出せる人」へ——新しい開発スタイルの提案。",[105],{"id":106,"name":107,"slug":108},38,"トレンド","trend",[110,111],{"id":51,"name":52,"slug":53},{"id":112,"name":113,"slug":114},79,"プロジェクト管理","project-management","AI時代のエンジニアの価値は「技術を使える」から「AIから最適な結果を引き出せる」へ。言語化力・判断力・PM力が鍵。",{"slug":100,"title":101,"thumbnail":17},{"slug":75,"title":76,"thumbnail":17}]