フォームやAPIに「ログインしているユーザーになりすまして操作を送りつけられる」CSRF(クロスサイトリクエストフォージェリ)の対策を、そのままコピペして使える形でまとめました。
- 対象: 自作フォーム・管理画面・お問い合わせ機能を PHP や WordPress で運用している Web 担当者・制作会社の方
- 解決すること: 退会・パスワード変更・投稿削除・設定変更などの重要操作が、他サイトから勝手に実行されるのを防ぐ
- 前提知識: HTML フォームと PHP の基本(専門用語には都度補足を付けます)
SQLインジェクションや XSS と並ぶ、Web アプリで作り込みやすい古典的な穴です。仕組みを1分で押さえたうえで、実装に入ります。
CSRF とは何か(1分で)
CSRF は、利用者のブラウザを踏み台にして、ログイン済みの状態を悪用する攻撃です。
ポイントは「Cookie が自動で送られる」というブラウザの仕様にあります。利用者が自分のサイト(例: example.com)にログインしていると、ブラウザはそのサイト宛のリクエストにセッション Cookie を自動で付けます。ここで利用者が別の悪意あるページを開き、そのページが裏で example.com に対してフォーム送信を行うと、利用者本人のセッション Cookie が付いたままリクエストが飛んでしまいます。サーバー側は「ログイン済みの本人からの正当な操作」と誤認して処理してしまう、というのが CSRF の本質です。
つまり CSRF は「入力値の中身」ではなく「そのリクエストは、本当に自サイトの画面から送られたものか?」を検証できていないことが原因です。ここでは攻撃の再現手順には踏み込まず、同じ被害を防ぐ側の実装だけを扱います。
対策の全体像:3つの柱
CSRF 対策は次の3点を組み合わせるのが定石です。1つだけでは穴が残ります。
- CSRFトークンの検証 — 自サイトの画面が発行した「合言葉」を、フォーム送信時に照合する(中心的な対策)
- SameSite Cookie — 他サイト起点のリクエストにセッション Cookie を付けさせない(ブラウザ側の防御)
- 安全な設計 — 状態を変える操作は必ず POST(GET でやらない)、必要なら Origin/Referer も補助的に確認
順に実装していきます。
1. PHP の素の実装:CSRFトークン
セッション開始時にランダムなトークンを1つ生成し、セッションに保存します。random_bytes() で暗号論的に安全な乱数を使うのが必須です(rand() や uniqid() は推測されうるため不可)。
<?php
session_start();
// トークンが未発行ならセッションに1つ生成して保存
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); // 64文字の16進文字列
}
$csrf_token = $_SESSION['csrf_token'];
フォーム側では、このトークンを hidden フィールドに埋め込みます。表示時は必ず htmlspecialchars() でエスケープします。
<form action="/update.php" method="post">
<input type="hidden" name="csrf_token"
value="<?php echo htmlspecialchars($csrf_token, ENT_QUOTES, 'UTF-8'); ?>">
<!-- 本来のフォーム項目 -->
<button type="submit">更新する</button>
</form>
受け取り側(POST を処理する update.php)では、送られてきたトークンとセッションのトークンを照合します。比較には必ず hash_equals() を使い、タイミング攻撃を避けます(== や === での文字列比較は使わない)。
<?php
session_start();
// POST 以外で状態を変えない(GET での更新は禁止)
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
$posted = $_POST['csrf_token'] ?? '';
if (empty($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $posted)) {
http_response_code(403);
exit('CSRF token mismatch'); // 本番では詳細をログにのみ残す
}
// ここまで通れば正当な送信。実際の更新処理へ
トークンはセッション単位で使い回して問題ありません(1リクエストごとに使い捨てる必要はなく、使い捨てにするとタブ複数開き・戻る操作で誤検知が増えます)。ログアウト時にセッションごと破棄されれば十分です。
2. SameSite Cookie でブラウザ側からも防ぐ
SameSite 属性は、他サイト起点のリクエストにセッション Cookie を付けるかどうかをブラウザに指示するものです。Lax にすると、他サイトからの POST 送信には Cookie が付かなくなり、CSRF の大部分を土台から無効化できます。
PHP のセッション Cookie に付ける場合は、session_start() の前にパラメータを設定します。
<?php
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true, // HTTPS 必須(HTTP では送らせない)
'httponly' => true, // JavaScript から読めなくする
'samesite' => 'Lax', // 他サイト起点の POST に付けない
]);
session_start();
php.ini でサイト全体に効かせる場合はこちら。
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = Lax
アプリを直接触れず、リバースプロキシで一律に付与したい場合の例も挙げておきます。nginx(1.19.3 以降)は proxy_cookie_flags が使えます。
# upstream が返す Set-Cookie に属性を追記
proxy_cookie_flags ~ secure samesite=lax;
Apache(mod_headers)なら次のように書けます。
Header always edit Set-Cookie ^(.*)$ "$1; SameSite=Lax; Secure"
Strict ではなく Lax を既定にするのが安全側の推奨です。Strict は外部リンクから遷移した直後のリクエストにも Cookie を付けないため、「メールのリンクを踏んだらログアウト状態に見える」といった正規利用の不便を生みがちです。SameSite=None は Cookie をクロスサイトで送る設定なので、CSRF 対策としては選びません(どうしても必要な決済連携などに限定)。
SameSite の詳しい挙動は MDN のリファレンス(https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Set-Cookie/SameSite )が正確です。ただし SameSite は古いブラウザや例外的な経路で効かないこともあるため、トークン検証との二段構えが前提です。
3. WordPress での実装:nonce を使う
WordPress を使っている場合は、自前でトークンを作らず**nonce(number used once の略。用途と時間で限定した合言葉)**の仕組みを使います。公式のセキュリティ API(https://developer.wordpress.org/apis/security/nonces/ )に沿って実装します。
フォームには wp_nonce_field() で hidden フィールドを出力します。第1引数は「操作を表す一意な文字列(action)」です。
<?php
// フォーム内で nonce フィールドを出力(action は操作ごとに変える)
wp_nonce_field('delete_post_' . $post_id, 'my_nonce');
?>
<!-- 送信ボタンなど -->
受け取り側では check_admin_referer() で検証します。不正なら WordPress が処理を止めてくれます。
<?php
// 管理画面のフォーム送信を処理する箇所
check_admin_referer('delete_post_' . $post_id, 'my_nonce');
// 権限も必ず併せて確認する(nonce は「なりすまし防止」であって権限確認ではない)
if (!current_user_can('delete_post', $post_id)) {
wp_die('権限がありません');
}
// ここから実処理
Ajax や REST API 経由の場合は、それぞれ専用の検証関数を使います。
<?php
// 管理画面 Ajax(admin-ajax.php)ハンドラの冒頭
check_ajax_referer('my_ajax_action', 'nonce');
// REST API はリクエストヘッダ X-WP-Nonce を wp_create_nonce('wp_rest') で発行し、
// register_rest_route の permission_callback で権限を確認する
WordPress の nonce は1回限りの使い捨てではなく、action とユーザー・セッションに紐づいた時間制限つき(既定で最長24時間)のトークンです。厳密なワンタイムトークンではない点は理解しておきましょう。それでも「その操作画面を実際に開いた本人からの送信か」を十分に担保できます。
4. 補助:Origin / Referer の確認
トークン検証が主役ですが、重要操作では Origin ヘッダの確認を補助的に足すと堅くなります。Origin は多くのブラウザが自動で付け、偽装が難しいためです。
<?php
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed = 'https://example.com'; // 自サイトのオリジンに置き換える
// Origin があるのに自サイトと一致しないときだけ弾く
if ($origin !== '' && $origin !== $allowed) {
http_response_code(403);
exit('Origin mismatch');
}
Referer は環境によって送られないことがあるため、**「無ければ通す」ではなく「あるなら一致を確認する」**という補助的な使い方に留めます。これ単独を主対策にはしません。
よくある間違い(ここを外すと無効化する)
-
GET で状態を変える:
?action=delete&id=5のような GET リクエストで削除・更新をしている。GET は他サイトから画像タグ1つで発火でき、SameSite=Lax でも一部通るため、状態変更は必ず POST にする。 - トークンを URL に載せる: クエリ文字列に付けると Referer やアクセスログ経由で漏れる。必ず hidden フィールド(またはリクエストヘッダ)で送る。
- 検証を一部の画面で忘れる: 新しく追加したフォームや API だけ検証が抜けがち。状態を変える入口を一覧化し、全経路に適用する。
-
比較に
==を使う: PHP の緩い比較は型変換で予期せぬ一致を起こす。照合はhash_equals()で行う。 -
nonce を権限確認の代わりにする: nonce は「本人の画面からの送信か」を確かめるだけ。「その人にその操作の権限があるか」は
current_user_can()で別途必ず確認する。
まとめ
- CSRF は入力値ではなく「そのリクエストが自サイトの画面由来か」を検証できていないことが原因。
- 対策は ①トークン検証(
random_bytes+hash_equals)②SameSite=Lax Cookie ③状態変更は POST + 補助的な Origin 確認の組み合わせ。 - WordPress は自作せず **nonce API(
wp_nonce_field/check_admin_referer/check_ajax_referer/ X-WP-Nonce)**を使い、権限確認と必ずセットにする。
XSS でトークンを盗まれると CSRF 対策も崩れるため、出力エスケープや Cookie 属性とあわせた多層防御が前提です。関連記事もあわせてどうぞ。
関連記事
- XSSを防ぐPHP・WordPressの出力エスケープ実装
- SQLインジェクションを防ぐPHP・WordPressの書き方
- Cookieのセキュリティ属性 SameSite / Secure / HttpOnly の正しい付け方
本記事のような実装の抜けは、Web サイトを9つの守りでまるごと守るサイトドックでまとめて対策できます → https://sitedock.jp