2026年9月21日
2026年9月21日
PHPのCSRF対策の実装方法(トークン・SameSite・WordPressのnonce)
PHP で CSRF(クロスサイトリクエストフォージェリ)を防ぐ基本は、状態を変える処理を POST などに限定し、セッションに保存した推測できないトークンをフォームに埋め込んで、受け取ったときに照合すること です。これに Cookie の SameSite 属性と Origin ヘッダーの確認を重ねると、防御が厚くなります。この記事では、素の PHP での実装例、Ajax での扱い、WordPress のテーマやプラグインで使う nonce の書き方、確認方法までを説明します。
CSRF とはどんな攻撃か
CSRF は、ログイン中の利用者に、本人の意図しないリクエストを送らせる攻撃です。たとえば利用者が管理画面にログインしたまま別のサイトを開くと、そのページに仕込まれたフォームが自動で送信され、パスワード変更や記事の削除が実行される、といった被害が起こり得ます。
ブラウザは、どのサイトから送られたリクエストでも、宛先サイトの Cookie を付けて送るのが基本の動作です。そのため、サーバー側で「ログインしているか」だけを見ていると、本人の操作と攻撃者に誘導された操作を区別できません。CSRF 対策は、リクエストが自分のサイトの画面から送られたものかを確かめる仕組み を追加することだと言えます。
対策が必要なのは、データの登録・更新・削除、設定変更、送金や注文など、状態を変える処理です。逆に言えば、状態を変える処理を GET で受け付けないことが最初の前提になります。GET はリンクや画像タグからでも送れてしまうためです。
トークン方式の実装(素の PHP)
最も一般的なのは、セッションごとにランダムなトークンを発行し、フォームの hidden 項目に入れて送ってもらい、サーバー側で照合する方式です(シンクロナイザートークン方式)。
<?php
// csrf.php
declare(strict_types=1);
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true, // HTTPS のサイトで有効にする
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_verify(?string $token): bool
{
return is_string($token)
&& isset($_SESSION['csrf_token'])
&& hash_equals($_SESSION['csrf_token'], $token);
}
実装のポイントは3つです。
- **トークンは
random_bytes()で作る**:rand()やuniqid()、時刻のハッシュは推測される余地があるので使いません。random_bytes()は暗号論的に安全な乱数を返します。 - **比較は
hash_equals()で行う**:===でも結果は同じですが、比較にかかる時間から値を推測されるタイミング攻撃を避けるため、秘密の値の比較にはhash_equals()を使うのが定石です。 - ログイン時にセッション ID を作り直す: ログイン成功時に
session_regenerate_id(true)を呼び、トークンも作り直します。セッション固定攻撃への対策を兼ねています。
フォーム側では、トークンをエスケープして hidden 項目に出力します。
<?php require __DIR__ . '/csrf.php'; ?>
<form method="post" action="/profile/update.php">
<input type="hidden" name="csrf_token"
value="<?= htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8') ?>">
<label>表示名 <input type="text" name="display_name"></label>
<button type="submit">保存</button>
</form>
受け取る側では、処理の最初でメソッドとトークンを確認し、不正なら何もせずに終了します。
<?php
// /profile/update.php
require __DIR__ . '/../csrf.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
http_response_code(403);
exit('リクエストを確認できませんでした。画面を再読み込みしてやり直してください。');
}
// ここから先で、入力値の検証とログイン状態・権限の確認を行ってから更新する
トークンの照合は「自分の画面から送られたか」を確かめるだけで、入力値が正しいかや、その利用者に操作する権限があるかは別に確認が要ります。CSRF 対策と入力検証・権限チェックは役割が違うので、どちらも省略しないでください。
Ajax(fetch)でトークンを送る
JavaScript からリクエストを送る場合は、トークンをページの meta タグなどに出力し、リクエストヘッダーに入れて送る方法がよく使われます。
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8') ?>">
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/favorite.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token,
},
body: JSON.stringify({ postId: 123 }),
credentials: 'same-origin',
});
サーバー側では、ヘッダーの値を $_SERVER['HTTP_X_CSRF_TOKEN'] で受け取って、同じ csrf_verify() で照合します。
if (!csrf_verify($_SERVER['HTTP_X_CSRF_TOKEN'] ?? null)) {
http_response_code(403);
exit;
}
SameSite 属性と Origin の確認を重ねる
トークン方式に加えて、次の対策を組み合わせると、どれか一つに不備があっても被害を抑えやすくなります。
- Cookie の SameSite 属性:
Laxにすると、別サイトからの POST などではセッション Cookie が送られなくなります。主要ブラウザは属性を指定しない Cookie を Lax として扱う方向で実装を進めてきましたが、挙動はブラウザやバージョンによって差があります(2026年10月現在)。ブラウザの既定値に頼らず、上の例のように明示的に指定してください。Strictはより厳しい代わりに、外部サイトのリンクから来たときにログイン状態が引き継がれないなど、使い勝手に影響します。 - Origin ヘッダーの確認: 多くのブラウザは POST などのリクエストに
Originヘッダーを付けます。値が自サイトのものでなければ拒否する、という確認を追加できます。
$allowedOrigin = 'https://example.com'; // 自サイトのオリジンに置き換える
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '' && $origin !== $allowedOrigin) {
http_response_code(403);
exit;
}
Origin ヘッダーは古いブラウザや一部の経路で付かないことがあるため、この例では「付いていて、かつ違う場合」だけ拒否しています。あくまで補助的な確認として使い、トークンの照合は残してください。
なお、XSS(クロスサイトスクリプティング)の脆弱性があると、攻撃者のスクリプトがページ内のトークンを読み取れるため、CSRF 対策は効果を失います。出力時のエスケープなど、XSS 対策と合わせて考える必要があります。
WordPress での CSRF 対策(nonce)
WordPress のテーマやプラグインを作る場合は、自前のトークンではなく、コアの nonce 関数を使います。nonce は、ログイン中のユーザー・操作名(action)・時間帯から生成される値で、既定では最大で約24時間有効です。名前に反して「1回だけ使える値」ではない点に注意してください。
管理画面のフォームに nonce を埋め込み、保存処理で検証する例です。
// フォームの出力
wp_nonce_field( 'myplugin_save_settings', 'myplugin_nonce' );
// admin-post.php で受ける保存処理
add_action( 'admin_post_myplugin_save', 'myplugin_handle_save' );
function myplugin_handle_save() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( '権限がありません。', 'myplugin' ), '', 403 );
}
check_admin_referer( 'myplugin_save_settings', 'myplugin_nonce' );
$value = isset( $_POST['myplugin_option'] )
? sanitize_text_field( wp_unslash( $_POST['myplugin_option'] ) )
: '';
update_option( 'myplugin_option', $value );
wp_safe_redirect( admin_url( 'options-general.php?page=myplugin&updated=1' ) );
exit;
}
check_admin_referer() は nonce が無効な場合に処理を止めます。自分でエラー処理をしたい場合は wp_verify_nonce() の戻り値を確認します。Ajax(admin-ajax.php)では check_ajax_referer() を、REST API では wp_rest という action の nonce を X-WP-Nonce ヘッダーで送る仕組みを使います。いずれの場合も、nonce の確認に加えて current_user_can() による権限チェックと、入力値のサニタイズ・出力時のエスケープを組み合わせてください。
実装を確認する方法
対策が効いているかは、自分が管理するサイトの開発環境やステージング環境で確認します。
- フォームの hidden 項目を開発者ツールで書き換えてから送信し、403 などで拒否されることを確かめる。
- トークンの項目を削除して送信し、同じく拒否されることを確かめる。
- 別のオリジン(ローカルに用意した別ポートのページなど)から同じフォームを送信し、更新されないことを確かめる。
- 正しい手順で送信したときに、問題なく保存できることを確かめる。
拒否されたときのメッセージは、利用者が再読み込みでやり直せるような案内にしておくと、セッション切れで正規の利用者が困る場面を減らせます。他人のサイトに対してこうした送信を試すのは不正アクセスにあたるおそれがあるので、確認は自分の環境に限ってください。
まとめ
- 状態を変える処理は POST などに限定し、GET では受け付けない。
random_bytes()で作ったトークンをセッションに保存し、フォームや Ajax のヘッダーで送らせてhash_equals()で照合する。- SameSite 属性の明示と Origin ヘッダーの確認を重ね、XSS 対策も合わせて行う。
- WordPress では
wp_nonce_field()とcheck_admin_referer()などのコア関数を使い、権限チェックとサニタイズを組み合わせる。 - 書き換え・削除・別オリジンからの送信で、拒否されることを自分の環境で確かめる。