2026年11月12日
2026年11月12日
WordPress CSRF Protection: Nonces for Forms, Ajax and the REST API
This article is a localized edition of the Japanese original. Read the Japanese original
What CSRF is and what nonces do
CSRF (cross-site request forgery) is an attack that lures a logged-in user to a malicious page and makes their browser send unintended requests to your site (changing settings, deleting posts and so on). Because the browser attaches the site's cookies automatically, the server cannot tell from the cookies alone whether the user really meant to do it.
WordPress embeds a token called a nonce in forms and links and verifies it on the receiving side, confirming that the request came from a screen on your own site. The attacker's page does not know the correct nonce, so the request fails verification.
Properties of WordPress nonces (based on the wp_create_nonce() / wp_verify_nonce() source):
- A nonce is a 10-character hash built from the action name, user ID, login session and time window. It does not validate for another user or another action.
- Despite the name, it is not single-use. The same value can be reused any number of times while it is valid.
- By default it is valid for 12 to 24 hours.
wp_verify_nonce()returns1if it was created 0-12 hours ago,2for 12-24 hours, andfalseif it is invalid. - You can change the lifetime with the
nonce_lifefilter (seconds, default 86400).
A nonce only confirms where the request came from, not whether the user is allowed to perform the action. Always do both: verify the nonce and check capabilities with current_user_can().
Which function to use
| Function | Use | On failed verification |
|---|---|---|
wp_nonce_field( $action, $name ) |
Outputs a hidden nonce field (and a referer field) in a form | — |
wp_create_nonce( $action ) |
Creates just the nonce value (for passing to JavaScript, etc.) | — |
wp_nonce_url( $url, $action ) |
Adds _wpnonce to a URL (delete links, etc.) |
— |
wp_verify_nonce( $nonce, $action ) |
Verifies and returns 1 / 2 / false |
Does not stop; you branch yourself |
check_admin_referer( $action, $name ) |
Verifies admin forms and links | Shows an HTTP 403 error screen and stops |
check_ajax_referer( $action, $name ) |
Verifies Ajax requests | During Ajax, returns -1 with HTTP 403 and stops |
wp_nonce_field() uses the field name _wpnonce when you omit it. When the field name is omitted, check_ajax_referer() looks for _ajax_nonce and then _wpnonce.
Protecting a custom form (admin-post.php)
Point the form at admin-post.php and handle it with the hook that matches the action value.
In the template (form output):
<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">Save</button>
</form>
Handler (in a plugin or 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( 'Invalid request.', 'Error', array( 'response' => 403 ) );
}
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( 'You do not have permission.', 'Error', 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;
}
$_POSTvalues have slashes added by WordPress, so remove them withwp_unslash()before use.admin_post_{action}fires only for logged-in users. Handle forms from logged-out visitors withadmin_post_nopriv_{action}.
Forms for logged-out visitors
Logged-out visitors have user ID 0 and no session, so the nonce is the same for every visitor for the same action and time window. CSRF abuses a logged-in user's privileges, so a nonce does little for a contact form used by logged-out visitors. Handle spam by other means (CAPTCHA, rate limiting and so on).
Also, with page caching, an expired nonce embedded in the cached HTML can be served and submissions fail. Exclude pages with forms from the cache, or fetch the nonce via Ajax after the page loads.
Protecting Ajax (admin-ajax.php)
Pass the nonce to JavaScript with wp_localize_script() and include it when sending.
<?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 (an example without 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 = 'Likes ' + json.data.likes;
}
});
});
});
check_ajax_referer() returns -1 with HTTP 403 and stops on failure, so the code after it runs only when the nonce is valid.
Protecting an admin settings page
On a custom settings page, pair wp_nonce_field() with 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>Settings saved.</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( 'Save', 'primary', 'myplugin_submit' ); ?>
</form>
</div>
<?php
}
If you use the Settings API (register_setting(), posting to options.php, settings_fields()), settings_fields() outputs the nonce field and options.php verifies it, so you do not need to write nonce code yourself.
GET actions such as delete links
When a link such as "Delete" triggers an action, add a nonce with wp_nonce_url() and call check_admin_referer() on the receiving side.
<?php
$url = wp_nonce_url(
admin_url( 'admin-post.php?action=myplugin_delete&item=' . absint( $item_id ) ),
'myplugin_delete_' . absint( $item_id )
);
Including the item ID in the action name prevents one item's nonce from being reused to delete another. On the receiving side, verify with the same name, as in check_admin_referer( 'myplugin_delete_' . absint( $_GET['item'] ) ).
Protecting the REST API
When you call the REST API with the login cookie, WordPress requires a nonce with the action wp_rest (authentication in the REST API Handbook). Send it in the X-WP-Nonce header or the _wpnonce parameter.
- A request without a nonce is treated as logged out (user ID 0), even if the user is logged in.
- A wrong or expired nonce returns
rest_cookie_invalid_nonce(HTTP 403). - A response that passes verification carries a fresh nonce in the
X-WP-Nonceheader. On screens that stay open for a long time, use it to refresh your stored value. - WordPress core verifies it in
rest_cookie_check_errors(), so your own endpoints do not need to verify the nonce. Check capabilities in thepermission_callbackofregister_rest_route().
<?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() outputs nothing unless you give it the handle of a script that has already been registered or enqueued (here myplugin-rest).
External calls that authenticate without cookies, such as with application passwords, do not need this nonce, because the browser does not attach those credentials automatically.
Common problems
- An error screen saying
The link you followed has expired.appears:check_admin_referer()failed. Check whether the form was left open too long, whether the action or field name differs between output and verification, or whether a page cache is serving an old nonce. - It fails after logging out and back in: nonces are tied to the login session, so logging in again invalidates earlier nonces.
- Ajax returns
-1:check_ajax_referer()failed. Check that the field name you send (noncein the example) matches the second argument. - To track down the cause, see also Debugging nonce verification errors in WordPress.
Summary
- For forms, output the nonce with
wp_nonce_field()and verify withwp_verify_nonce()orcheck_admin_referer(). - For Ajax, pass it with
wp_create_nonce()and verify withcheck_ajax_referer(). - For the REST API, send a
wp_restnonce in theX-WP-Nonceheader; core does the verification. - A nonce checks the origin, not permissions. Always pair nonce verification with
current_user_can(). - For defenses based on the cookie SameSite attribute, see Preventing CSRF with SameSite cookies.
Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)