2026年5月20日
2026年5月20日
WordPressのCSRF対策:nonceでフォーム・Ajax・REST APIを守る方法
CSRFとnonceの役割
CSRF(クロスサイトリクエストフォージェリ)は、ログイン中の利用者に罠のページを開かせ、そのブラウザからサイトへ意図しないリクエスト(設定の変更、投稿の削除など)を送らせる攻撃です。ブラウザはサイトの Cookie を自動で付けて送るため、サーバー側は「本人の操作か」を Cookie だけでは見分けられません。
WordPress では、フォームやリンクに nonce と呼ばれるトークンを埋め込み、受け取った側で検証することで、そのリクエストが自サイトの画面から送られたものかを確かめます。攻撃者のページは正しい nonce を知らないため、検証で弾かれます。
WordPress の nonce の性質(wp_create_nonce() / wp_verify_nonce() のソースによる):
- 「アクション名」「ユーザー ID」「ログインセッション」「時間帯」から作られる 10 文字のハッシュです。別のユーザーや別のアクションでは通りません。
- 名前に反して「1 回限り」ではありません。有効期間内なら同じ値を何度でも使えます。
- 既定の有効期間は 12~24 時間です。
wp_verify_nonce()は、作成から 0~12 時間なら1、12~24 時間なら2、無効ならfalseを返します。 - 期間は
nonce_lifeフィルター(秒、既定 86400)で変えられます。
nonce が確かめるのは「どこから送られたか」だけで、「その操作をしてよい人か」は確かめません。nonce の検証と current_user_can() による権限チェックは、いつも両方行います。
関数の使い分け
| 関数 | 用途 | 検証に失敗したとき |
|---|---|---|
wp_nonce_field( $action, $name ) |
フォームに hidden の nonce 欄(と参照元欄)を出力 | — |
wp_create_nonce( $action ) |
nonce の値だけを作る(JavaScript に渡すときなど) | — |
wp_nonce_url( $url, $action ) |
URL に _wpnonce を付ける(削除リンクなど) |
— |
wp_verify_nonce( $nonce, $action ) |
検証して 1 / 2 / false を返す |
処理は止まらない。自分で分岐する |
check_admin_referer( $action, $name ) |
管理画面のフォーム・リンクの検証 | HTTP 403 のエラー画面を表示して停止 |
check_ajax_referer( $action, $name ) |
Ajax の検証 | Ajax 中は -1 と HTTP 403 を返して停止 |
wp_nonce_field() の欄名は省略すると _wpnonce です。check_ajax_referer() は欄名を省略すると _ajax_nonce、次に _wpnonce を探します。
自作フォームを守る(admin-post.php)
フォームの送信先を admin-post.php にし、action の値に対応するフックで処理します。
テンプレート側(フォームの出力):
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'myplugin_save_note', 'myplugin_note_nonce' ); ?>
<input type="hidden" name="action" value="myplugin_save_note">
<textarea name="note" required></textarea>
<button type="submit">保存</button>
</form>
処理側(プラグインまたは functions.php):
<?php
add_action( 'admin_post_myplugin_save_note', 'myplugin_handle_save_note' );
function myplugin_handle_save_note(): void {
$nonce = isset( $_POST['myplugin_note_nonce'] )
? sanitize_text_field( wp_unslash( $_POST['myplugin_note_nonce'] ) )
: '';
if ( ! wp_verify_nonce( $nonce, 'myplugin_save_note' ) ) {
wp_die( '不正なリクエストです。', 'エラー', array( 'response' => 403 ) );
}
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( '権限がありません。', 'エラー', array( 'response' => 403 ) );
}
$note = isset( $_POST['note'] )
? sanitize_textarea_field( wp_unslash( $_POST['note'] ) )
: '';
update_user_meta( get_current_user_id(), 'myplugin_note', $note );
wp_safe_redirect( add_query_arg( 'saved', '1', wp_get_referer() ?: home_url( '/' ) ) );
exit;
}
$_POSTの値は WordPress によってスラッシュが付けられているため、wp_unslash()で外してから使います。admin_post_{action}はログイン中の利用者だけが呼ばれます。ログインしていない訪問者のフォームはadmin_post_nopriv_{action}で受けます。
ログインしていない訪問者のフォームについて
ログインしていない訪問者はユーザー ID が 0 でセッションも無いため、nonce は「同じアクション・同じ時間帯なら全訪問者で同じ値」になります。CSRF は「ログイン中の権限を悪用する攻撃」なので、ログインしていない人のお問い合わせフォームでは nonce の効果は限られます。スパム対策は別の手段(CAPTCHA、送信頻度の制限など)で行います。
また、ページキャッシュを使っていると、HTML に埋め込まれた nonce が期限切れのまま配信され、送信が失敗することがあります。フォームのあるページをキャッシュから外すか、nonce をページ表示後に Ajax で取得する作りにします。
Ajaxを守る(admin-ajax.php)
nonce を wp_localize_script() で JavaScript に渡し、送信時に付けます。
<?php
add_action( 'wp_enqueue_scripts', function (): void {
if ( ! is_user_logged_in() ) {
return;
}
wp_enqueue_script(
'myplugin-like',
get_stylesheet_directory_uri() . '/js/like.js',
array(),
'1.0.0',
true
);
wp_localize_script( 'myplugin-like', 'mypluginLike', array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'myplugin_like' ),
) );
} );
add_action( 'wp_ajax_myplugin_like', function (): void {
check_ajax_referer( 'myplugin_like', 'nonce' );
if ( ! current_user_can( 'read' ) ) {
wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
}
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
if ( ! $post_id || ! get_post( $post_id ) ) {
wp_send_json_error( array( 'message' => 'invalid post' ), 400 );
}
$count = (int) get_post_meta( $post_id, 'myplugin_likes', true );
update_post_meta( $post_id, 'myplugin_likes', $count + 1 );
wp_send_json_success( array( 'likes' => $count + 1 ) );
} );
js/like.js(jQuery を使わない例):
document.querySelectorAll('.myplugin-like').forEach(function (button) {
button.addEventListener('click', function () {
const body = new URLSearchParams({
action: 'myplugin_like',
nonce: mypluginLike.nonce,
post_id: button.dataset.postId
});
fetch(mypluginLike.ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
body: body
})
.then(function (res) { return res.json(); })
.then(function (json) {
if (json.success) {
button.textContent = 'いいね ' + json.data.likes;
}
});
});
});
check_ajax_referer() は失敗すると -1 と HTTP 403 を返して処理を止めるため、その後のコードは nonce が正しい場合だけ実行されます。
管理画面の設定ページを守る
独自の設定ページでは wp_nonce_field() と check_admin_referer() を組にします。
<?php
add_action( 'admin_menu', function (): void {
add_options_page( 'My Settings', 'My Settings', 'manage_options', 'myplugin-settings', 'myplugin_settings_page' );
} );
function myplugin_settings_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
if ( isset( $_POST['myplugin_submit'] ) ) {
check_admin_referer( 'myplugin_settings', 'myplugin_settings_nonce' );
$value = isset( $_POST['myplugin_value'] )
? sanitize_text_field( wp_unslash( $_POST['myplugin_value'] ) )
: '';
update_option( 'myplugin_value', $value );
echo '<div class="notice notice-success"><p>保存しました。</p></div>';
}
$current = get_option( 'myplugin_value', '' );
?>
<div class="wrap">
<h1>My Settings</h1>
<form method="post">
<?php wp_nonce_field( 'myplugin_settings', 'myplugin_settings_nonce' ); ?>
<input type="text" name="myplugin_value" value="<?php echo esc_attr( $current ); ?>">
<?php submit_button( '保存', 'primary', 'myplugin_submit' ); ?>
</form>
</div>
<?php
}
設定 API(register_setting() と options.php への送信、settings_fields())を使う場合は、settings_fields() が nonce 欄を出力し、options.php 側で検証されるため、自分で nonce を書く必要はありません。
削除リンクなどGETの操作
「削除」のようなリンクで処理を行う場合は、wp_nonce_url() で nonce を付け、受け側で check_admin_referer() を呼びます。
<?php
$url = wp_nonce_url(
admin_url( 'admin-post.php?action=myplugin_delete&item=' . absint( $item_id ) ),
'myplugin_delete_' . absint( $item_id )
);
アクション名に対象の ID を含めると、ある項目の nonce を別の項目の削除に使い回されるのを防げます。受け側では check_admin_referer( 'myplugin_delete_' . absint( $_GET['item'] ) ) のように同じ名前で検証します。
REST APIを守る
ログイン Cookie で REST API を呼ぶ場合、WordPress はアクション名 wp_rest の nonce を要求します(REST API ハンドブックの認証の説明)。nonce は X-WP-Nonce ヘッダーか _wpnonce パラメーターで送ります。
- nonce が無いリクエストは、ログイン中でも「未ログイン」(ユーザー ID 0)として扱われます。
- nonce が誤っている・期限切れの場合は、
rest_cookie_invalid_nonce(HTTP 403)が返ります。 - 検証に通ったレスポンスには、新しい nonce が
X-WP-Nonceヘッダーで返されます。長く開いておく画面では、これで手元の値を更新できます。 - 検証は WordPress 本体の
rest_cookie_check_errors()が行うため、自作のエンドポイントで nonce を検証する必要はありません。権限はregister_rest_route()のpermission_callbackで確認します。
<?php
add_action( 'wp_enqueue_scripts', function (): void {
wp_enqueue_script(
'myplugin-rest',
get_stylesheet_directory_uri() . '/js/rest.js',
array(),
'1.0.0',
true
);
wp_localize_script( 'myplugin-rest', 'mypluginRest', array(
'root' => esc_url_raw( rest_url() ),
'nonce' => wp_create_nonce( 'wp_rest' ),
) );
} );
fetch(mypluginRest.root + 'wp/v2/posts', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': mypluginRest.nonce
},
body: JSON.stringify({ title: 'New Post', status: 'draft' })
});
wp_localize_script() は、先に登録・読み込みしたスクリプトの名前(ここでは myplugin-rest)を指定しないと出力されません。
アプリケーションパスワードなど Cookie 以外で認証する外部からの呼び出しは、ブラウザが自動で資格情報を付けないため、この nonce は不要です。
よくあるつまずき
- 「辿ったリンクは期限が切れています。」と表示される:
check_admin_referer()の検証失敗です。フォームを長時間開いていた、アクション名や欄名が出力側と検証側で違う、ページキャッシュが古い nonce を配信している、のいずれかを確認します。 - ログアウト・再ログインすると通らない: nonce はログインセッションに結び付いているため、ログインし直すと以前の nonce は無効になります。
- Ajax が
-1を返す:check_ajax_referer()の失敗です。送っている欄名(例ではnonce)と第2引数が一致しているか確認します。 - 原因の調べ方は WordPressのnonce検証エラーをデバッグする方法 も参照してください。
まとめ
- フォームは
wp_nonce_field()で出力し、wp_verify_nonce()かcheck_admin_referer()で検証する。 - Ajax は
wp_create_nonce()で渡し、check_ajax_referer()で検証する。 - REST API は
wp_restの nonce をX-WP-Nonceヘッダーで送る。検証は本体が行う。 - nonce は送信元の確認であり、権限の確認ではない。nonce の検証と
current_user_can()を毎回セットで書く。 - Cookie の SameSite 属性による対策は SameSite Cookie で CSRF を防ぐ方法 を参照してください。