Webサイトにお問い合わせフォームを設置したものの、スパム対策をしていない——そんな状態に心当たりはありませんか?
ポートフォリオサイトや小規模なビジネスサイトでは、「まだアクセスも少ないし、後回しでいいだろう」と思いがちです。しかし、botはサイトの規模に関係なくフォームを見つけて攻撃してきます。対策なしの状態では、大量のスパムメールが送信されるリスクを常に抱えていることになります。
この記事では、完全無料で導入できる2層のスパム対策——Cloudflare TurnstileとハニーポットをNuxtのお問い合わせフォームに実装する方法を、実際にハマったポイントも含めて解説します。
無料で使えるスパム対策の選択肢
フォームのスパム対策にはいくつかの方法があります。代表的なものを比較してみましょう。
Google reCAPTCHA v3
もっとも広く使われているCAPTCHAサービスです。無料で使えますが、Googleにユーザーの行動データが送信される点がプライバシー面で気になるところ。また、2024年の料金体系変更により、無料枠は月10,000リクエストまでに縮小されました。小規模サイトでも超える可能性がある数字です。
Cloudflare Turnstile
Cloudflareが提供する無料のCAPTCHA代替サービスです。「Managed」モードでは、低リスクと判断されたユーザーにはチャレンジを表示せず、透過的に検証を完了します。プライバシーに配慮されており、ユーザー体験を損なわないのが大きな特長です。完全無料で利用できます。
ハニーポット
CSSで非表示にしたダミーのフォームフィールドを設置する手法です。人間には見えませんが、botはHTMLを解析してすべてのフィールドに入力するため、ここに値が入っていればbotと判定できます。実装がシンプルで、外部サービスに依存しないのがメリットです。
今回の選択:Turnstile × ハニーポットの2層防御
それぞれ単体でも効果はありますが、組み合わせることで死角をなくすことができます。
- ハニーポットで単純なbotをブロック(第1層)
- Turnstileでハニーポットを回避する高度なbotもブロック(第2層)
しかもどちらも無料。コストをかけずにしっかりとしたスパム対策が実現できます。
2層防御の設計思想
今回の設計で重要にしたポイントは3つです。
1. botに検知を悟らせない
ハニーポットに引っかかったbotには、あえて成功レスポンスを返します。「送信が拒否された」と分かるとbotの挙動が変わる可能性があるため、偽の成功を返して次の攻撃を防ぎます。
2. サーバーサイドで必ず検証する
Turnstileのトークン検証は必ずサーバーサイドで行います。クライアント側のチェックだけではDevToolsで簡単にバイパスされてしまうためです。フロントエンドでの制御(ボタンの無効化など)はあくまでUX向上のためであり、セキュリティはサーバー側で担保します。
3. シークレットキーをクライアントに露出させない
Turnstileには「サイトキー(公開)」と「シークレットキー(秘密)」の2つがあります。サイトキーはブラウザに渡しても問題ありませんが、シークレットキーは絶対にサーバー側にのみ配置します。NuxtのruntimeConfig(非public)を使うことで、クライアントバンドルに含まれないようにしています。
実装ガイド(Nuxt)
Step 1: Cloudflare Turnstileのキーを取得する
まずはCloudflareのアカウントを作成し(無料)、ダッシュボードからTurnstileウィジェットを登録します。(以下は2026年3月時点の画面構成です。UIが変更されている場合は公式ドキュメントを参照してください。)
- Cloudflareダッシュボードにログイン
- 左メニューの「アプリケーションセキュリティ」内にある「Turnstile」を選択(直接リンク)
- 「ウィジェットを追加」をクリックし、ウィジェット名とサイトのドメインを登録
- ウィジェットモードは「Managed」を選択(推奨)
- 作成後に表示されるサイトキーとシークレットキーを控える
サイトキーはフロントエンドに公開しても問題ないキー、シークレットキーはサーバーサイドでのみ使う秘密鍵です。取得したキーは環境変数(.env)に設定しておきます。
# frontend/.env
NUXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAA... # サイトキー
NUXT_TURNSTILE_SECRET_KEY=0x4AAAA... # シークレットキー
詳細な手順はCloudflare Turnstile公式ドキュメントを参照してください。
Step 2: パッケージの導入
npm install -D @nuxtjs/turnstile
nuxt.config.ts にモジュールを登録します。
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/turnstile'],
turnstile: {
siteKey: process.env.NUXT_PUBLIC_TURNSTILE_SITE_KEY || '',
},
runtimeConfig: {
turnstile: {
secretKey: '', // NUXT_TURNSTILE_SECRET_KEY
},
},
})
Step 3: ハニーポットの実装
フォームにCSSで完全に非表示にしたダミーフィールドを追加します。
<!-- ハニーポット: botのみが入力する隠しフィールド -->
<div class="contact-form__hp" aria-hidden="true">
<label for="website">Website</label>
<input
id="website"
v-model="formData.website"
type="text"
name="website"
autocomplete="off"
tabindex="-1"
>
</div>
.contact-form__hp {
position: absolute;
left: -9999px;
opacity: 0;
height: 0;
overflow: hidden;
}
ポイントはaria-hidden="true"とtabindex="-1"で、スクリーンリーダーやキーボード操作で到達しないようにすることです。display: noneではなく位置をオフスクリーンにすることで、より多くのbotを騙せます。
Step 4: Turnstileウィジェットの配置
<div class="contact-form__turnstile">
<NuxtTurnstile v-model="turnstileToken" />
</div>
<AppButton
:disabled="isSubmitting || !turnstileToken"
@click="handleSubmit"
>
{{ isSubmitting ? '送信中...' : '送信する' }}
</AppButton>
Turnstileの検証が完了するまで送信ボタンを無効化しています。Managedモードでは、多くの場合ユーザーが気づかないうちに検証が完了します。
Step 5: サーバーサイド検証
ここが最も重要な部分です。サーバー側でCloudflareのAPIにトークンを送って検証します。
// server/api/contact.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// 第1層: ハニーポットチェック
if (body.website) {
// botには成功を偽装して返す
return { success: true, message: 'お問い合わせを送信しました。' }
}
// 第2層: Turnstile トークン検証
const runtimeConfig = useRuntimeConfig(event)
const secret = runtimeConfig.turnstile?.secretKey
|| process.env.NUXT_TURNSTILE_SECRET_KEY
const result = await $fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
body: { secret, response: body.turnstileToken },
},
)
if (!result.success) {
throw createError({
statusCode: 400,
statusMessage: 'セキュリティ検証に失敗しました。',
})
}
// 検証通過 → メール送信処理へ
// ...
})
実装・テストで苦労したポイント
ローカル開発環境では問題なく動作したものの、本番環境に持っていくと次々と問題が発覚しました。原因の特定に時間がかかったポイントを共有します。
1. npmの依存関係の競合
最初の壁はnpm installでした。
npm error ERESOLVE could not resolve
npm error peer @nuxt/scripts@"^0.11.0 || ^0.12.0" from @nuxtjs/turnstile@1.1.1
npm error Found: @nuxt/scripts@0.13.2
@nuxtjs/turnstileが要求する@nuxt/scriptsのバージョンと、プロジェクトで使用しているバージョンが合わず、インストールが失敗しました。ローカルでは--legacy-peer-depsオプションで回避できましたが、CI/CDパイプラインのnpm ciでも同じエラーが発生。
解決策は、.npmrcファイルをプロジェクトに追加することでした。
# frontend/.npmrc
legacy-peer-deps=true
これにより、ローカルでもCI/CDでも一貫して依存関係の競合をスキップできるようになりました。
2. 本番環境でシークレットキーが読めない
デプロイ後、フォーム送信すると400エラー。サーバーログを確認すると、
{ "error-codes": ["missing-input-secret"], "success": false }
Cloudflareから「シークレットキーがない」と返されていました。.envにはちゃんとキーを設定していたのに、なぜ?
原因は、Nuxt(Nitro)の本番サーバーは.envファイルを自動で読み込まないという仕様でした。開発サーバー(nuxt dev)ではViteが.envを読み込んでくれますが、ビルド後の本番サーバーにはその仕組みがありません。
このプロジェクトではPM2でプロセス管理をしており、ecosystem.config.cjsで環境変数を定義していました。新しく追加したTurnstileのキーをこのファイルに追加し忘れていたのが原因です。
さらに、サーバーサイドのコードも@nuxtjs/turnstileモジュールが提供するverifyTurnstileToken関数に依存していましたが、この関数がruntimeConfigからキーを読み取る仕組みのため、環境変数が渡っていないとキーが空のままAPIを叩いてしまいます。最終的に、Cloudflare APIを直接呼び出す実装に変更し、process.envからの直接読み取りをフォールバックとして追加しました。
3. PM2の「restart」では環境変数が反映されない
ecosystem.config.cjsにキーを追加した後、pm2 restartしたのにまだ同じエラーが出る——これが一番時間を取られたポイントです。
pm2 restartは、既存のプロセスをそのまま再起動するだけで、ecosystem.config.cjsの変更は再読み込みしません。起動時にメモリに読み込んだ環境変数をそのまま使い続けるのです。
正しい手順は以下の通りです。
# ❌ これでは ecosystem.config.cjs の変更は反映されない
pm2 restart morinoupa
# ✅ 正しい方法: プロセスを削除して config を再読み込み
pm2 delete morinoupa
pm2 start ecosystem.config.cjs
pm2 save
この挙動はPM2のドキュメントを読めば書いてありますが、いざ本番環境でトラブルシューティングしていると見落としがちです。環境変数を変更したら、deleteしてからstartし直す——これを覚えておくだけで、同じ問題に遭遇したときに即座に解決できます。
教訓まとめ
- ローカルで動いても本番で動くとは限らない——特に環境変数の読み込み方法はローカルと本番で異なる
pm2 restart≠ 設定の再読み込み——pm2 delete+pm2 startが正しい手順- 外部モジュールの内部実装に依存しすぎない——ブラックボックスな関数よりも、自分で制御できるコードの方がデバッグしやすい
- サーバーログは最強のデバッグツール——
pm2 logsでエラーの詳細を確認する習慣をつける
まとめ
お問い合わせフォームのスパム対策は、サイトの規模に関わらず必要なセキュリティ対策です。
今回紹介したCloudflare Turnstile × ハニーポットの組み合わせなら、
- 費用ゼロで導入できる
- ユーザーにストレスを与えない(Managedモード)
- 2層の防御で単純なbotから高度なbotまで対応
- サーバーサイド検証でバイパスを防止
「まだアクセスが少ないから大丈夫」と思っていると、ある日突然スパムの洪水に見舞われるかもしれません。実装自体は1〜2時間程度で完了するので、まずは一歩踏み出してみてください。無料でもここまでしっかりとした対策が取れます。