問い合わせフォームに毎日届く英語のスパム、外部サービスへの自動リダイレクト、同じ内容の大量送信——。この記事は WordPress や PHP でフォームを運用しているWeb担当者・制作会社 向けに、スパムと不正送信を 多層(複数の対策の重ね掛け)で落とす定石5つ を、そのままコピペできる設定つきでまとめたものです。1つ入れるだけでは抜けられますが、組み合わせれば大半の自動送信は止まります。
そもそも「スパム」と「不正送信」は別物
まず整理します。フォームに来る迷惑送信は、対策の当てどころが2種類あります。
- スパム(自動送信ボット): プログラムがフォームを機械的に送信してくる。宣伝・SEOリンク・詐欺誘導が中心。数で来るのが特徴。
- 不正送信(CSRFなど): 利用者本人が意図しないリクエストを、外部サイトから送らされる。あるいは正規の送信フローを無視して直接エンドポイントを叩かれる。
前者は「人間かどうか」「送信ペースが異常でないか」で弾き、後者は「正規の画面から来た送信か」を検証して弾きます。1つの対策で全部を賄おうとせず、役割の違う対策を積むのが基本方針です。
対策は「軽くて誤検知の少ないもの」から順に重ねます。おすすめの導入順は次のとおりです。
- honeypot(ボットの大半をコストゼロで落とす)
- レート制限(連投・総当たりを止める)
- CSRFトークン(正規画面以外からの送信を弾く)
- Turnstile / reCAPTCHA(残りの高度なボットを判定)
- WordPress ならプラグイン設定で上記を底上げ
対策1: honeypot(見えない罠フィールド)
最もコストが低く、体感で最も効くのが honeypot です。人間には見えないがボットには見えるダミー入力欄を置き、そこに値が入っていたら送信を破棄します。CAPTCHA と違い、正規の利用者には一切手間が増えません。
まずフォームにダミー欄を追加します。CSS で画面外に飛ばし、autocomplete="off" と tabindex="-1" でキーボード操作からも外します(display:none はボットに見抜かれやすいので、位置で隠す方が堅牢です)。
<!-- 正規の入力欄 -->
<label>お名前 <input type="text" name="your_name" required></label>
<label>本文 <textarea name="message" required></textarea></label>
<!-- honeypot: 人間には見えない。ボットだけが入力する -->
<div style="position:absolute; left:-9999px;" aria-hidden="true">
<label>この欄は入力しないでください
<input type="text" name="website_url" tabindex="-1" autocomplete="off">
</label>
</div>
<!-- 送信までの経過時間を測る隠しフィールド(サーバー側で現在時刻を埋め込む) -->
<input type="hidden" name="form_rendered_at" value="<?php echo time(); ?>">
サーバー側では「罠欄に値が入っている」または「表示から送信までが速すぎる(人間なら数秒はかかる)」場合に破棄します。
<?php
// honeypot と送信速度でボットを判定する
$trap = $_POST['website_url'] ?? '';
if ($trap !== '') {
// ボット確定。成功したように見せて静かに破棄する(エラーを返すと学習される)
http_response_code(200);
exit;
}
$rendered_at = (int) ($_POST['form_rendered_at'] ?? 0);
$elapsed = time() - $rendered_at;
if ($rendered_at === 0 || $elapsed < 3) {
// 3秒未満での送信は自動化とみなす
http_response_code(200);
exit;
}
ポイントは、ボットを弾くときに あえてエラーを返さないことです。「弾かれた」と分かると回避を試みられるため、正常応答を返して黙って捨てます。
対策2: レート制限(連投・総当たりを止める)
同一送信元からの短時間の大量送信は、フォーム側の手前(Webサーバー)で落とすのが確実です。nginx なら limit_req で、フォームの送信先パスにレート制限をかけられます。
# http ブロックで送信元IP単位のゾーンを定義(10MBで約16万IP分)
limit_req_zone $binary_remote_addr zone=formlimit:10m rate=5r/m;
server {
# 問い合わせフォームの送信先だけに適用する
location = /contact/submit {
limit_req zone=formlimit burst=3 nodelay;
limit_req_status 429; # 上限超過は 429 を返す
# ... アプリへの proxy_pass など
}
}
rate=5r/m は1分あたり5回まで、burst=3 で瞬間的な3回分までは許容します。正規の利用者が1分に何度も送ることはまずないため、しきい値は厳しめでも実害が出にくい箇所です。
Webサーバーに手を入れられない共有ホスティングなどでは、PHP 側でセッション単位の簡易制限を入れます。
<?php
session_start();
$now = time();
$_SESSION['submit_log'] = array_filter(
$_SESSION['submit_log'] ?? [],
fn($t) => $t > $now - 60 // 直近60秒の記録だけ残す
);
if (count($_SESSION['submit_log']) >= 5) {
http_response_code(429);
exit('しばらく時間をおいて再送信してください。');
}
$_SESSION['submit_log'][] = $now;
対策3: CSRFトークン(正規画面以外からの送信を弾く)
外部サイトに置かれた罠フォームから送信させられる、あるいはエンドポイントを直接叩かれる不正送信は、正規のフォーム画面を経由したかどうかで判定します。表示時にワンタイムのトークンを発行してセッションに保存し、送信時に照合します。
<?php
session_start();
// フォーム表示時: トークンを生成してセッションと hidden の両方に持たせる
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
<input type="hidden" name="csrf_token"
value="<?php echo htmlspecialchars($_SESSION['csrf_token'] ?? '', ENT_QUOTES); ?>">
<?php
// 送信時: hash_equals で定数時間比較(タイミング差での推測を防ぐ)
$sent = $_POST['csrf_token'] ?? '';
$saved = $_SESSION['csrf_token'] ?? '';
if ($saved === '' || !hash_equals($saved, $sent)) {
http_response_code(400);
exit('不正なリクエストです。フォームを開き直してください。');
}
unset($_SESSION['csrf_token']); // 使い回しを防ぐため使用後に破棄
あわせて、送信を受け付けるエンドポイントは POST 限定にし、GET での送信を受け付けないようにします。Cookie の SameSite 属性も併用すると、クロスサイトからの自動送信をさらに抑えられます(下の関連記事参照)。
対策4: Turnstile / reCAPTCHA(残りの高度なボットを判定)
honeypot をすり抜ける高度なボットには CAPTCHA を足します。利用者の操作を増やしにくい Cloudflare Turnstile(無料・多くの場合クリック不要)か、reCAPTCHA v3(スコア判定)が扱いやすい選択肢です。ここでは Turnstile の例を示します。
フォームにウィジェットを置きます。
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form method="post" action="/contact/submit">
<!-- ... 入力欄 ... -->
<div class="cf-turnstile" data-sitekey="ここにサイトキー"></div>
<button type="submit">送信</button>
</form>
送信を受けたサーバー側で、Cloudflare に検証リクエストを送って結果を確認します。この検証を必ずサーバー側で行うのが要点です。フロントだけの表示では簡単に迂回されます。
<?php
// Turnstile のサーバー側検証
$secret = getenv('TURNSTILE_SECRET'); // シークレットはコードに直書きしない
$token = $_POST['cf-turnstile-response'] ?? '';
$ch = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'secret' => $secret,
'response' => $token,
'remoteip' => $_SERVER['REMOTE_ADDR'] ?? '',
]),
CURLOPT_TIMEOUT => 5,
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($result['success'])) {
http_response_code(400);
exit('人間による操作を確認できませんでした。');
}
reCAPTCHA v3 を使う場合は、検証先を https://www.google.com/recaptcha/api/siteverify にし、返ってきた score(0.0〜1.0)が一定値(例: 0.5)未満なら弾きます。
対策5: WordPress ならプラグイン設定で底上げする
WordPress で Contact Form 7 や同種のフォームを使っているなら、上記を最短で実現できます。
- Turnstile / reCAPTCHA: Contact Form 7 のインテグレーション設定でキーを登録するだけで、送信時のサーバー側検証まで有効になります。
- honeypot: 「Honeypot for Contact Form 7」等の追加プラグインで、専門知識なしに罠欄を追加できます。
-
CSRF・nonce: WordPress は投稿系の処理に
nonceの仕組みを持っています。テーマやカスタムフォームで独自にPOSTを受ける場合は、対策3の代わりに nonce を使います。
<?php
// テーマ側でカスタムフォームを扱う場合の nonce
// 表示時:
wp_nonce_field('contact_submit', 'contact_nonce');
// 送信時:
if (!isset($_POST['contact_nonce'])
|| !wp_verify_nonce($_POST['contact_nonce'], 'contact_submit')) {
wp_die('不正なリクエストです。', '', ['response' => 400]);
}
プラグインは入れっぱなしにせず、自動更新の対象に含めて最新に保つことも忘れないでください。フォーム系プラグインは狙われやすく、古いバージョンの放置がそのまま入口になります。
まとめ
- スパム(自動送信)と不正送信(CSRF等)は当てどころが違う。役割の違う対策を重ねるのが基本。
- コストゼロで最も効くのは honeypot。まずこれを入れる。
- 連投は レート制限(nginx
limit_reqかPHPの簡易制限)で手前で落とす。 - 外部からの不正送信は CSRFトークン / nonce と POST 限定で弾く。
- すり抜ける高度なボットに Turnstile / reCAPTCHA を足す。検証は必ずサーバー側で。
- WordPress ならプラグイン設定で上記を最短実現。プラグインは常に最新に。
すべてを一度に入れる必要はありません。honeypot → レート制限 → CSRF の順に足すだけでも、届くスパムは大きく減ります。
関連記事
- WordPressログイン総当たり対策5選
- Cookieのセキュリティ属性 SameSite / Secure / HttpOnly の正しい付け方
- WordPressセキュリティ 最低限やることチェックリスト10
本記事のフォーム周りの設定は、Webサイトを9つの守りでまるごと守る「サイトドック」の月次の定期健診でまとめて確認できます → https://sitedock.jp