Imagine an administrator is logged in to their site and visits another page that contains a hidden form. The form posts to the admin and deletes an event. The browser sends the administrator's login cookie along with it, so the request looks legitimate. This is cross-site request forgery (CSRF), and WordPress nonces are the defence.
CSRF in general is covered in the OWASP course. This lesson covers how WordPress implements the protection.
What a nonce is
A WordPress nonce is a short token generated from:
- an action string you choose, such as
myplugin_delete_event_42; - the current user and their login session;
- a time window.
Your page includes the token in the form or request. When the request arrives, WordPress recalculates the token and compares. An attacker's page cannot produce a valid token, because it does not know the user's session.
Some facts that matter in practice:
- It is not a true "number used once". A WordPress nonce can be reused until it expires. Its lifetime is a day by default, and a nonce is accepted for up to that long.
- It is tied to the user. A nonce created for one user is useless for another.
- It is tied to the action. A nonce for
myplugin_delete_event_42does not work for event 43 or for a different action.
What a nonce is not
A nonce proves intent: this request came from a page your site produced for this user. It does not prove permission.
Never use a nonce as the only check. A subscriber can load any page that contains a valid nonce for themselves and send it. Every handler must check a capability with current_user_can() as well. The two checks answer different questions: "is this user allowed?" and "did this user mean to do it?".
Nonces also give little protection to logged-out visitors, because every logged-out visitor is the same "user" to WordPress. For public forms, such as a newsletter sign-up, rely on validation, rate limiting and spam protection rather than nonces.
The functions
| Function | Creates or checks | Use in |
|---|---|---|
wp_create_nonce( $action ) | Creates and returns a nonce | Anywhere you need the raw value, such as data for JavaScript |
wp_nonce_field( $action, $name ) | Prints a hidden field with a nonce, plus a referer field | Forms |
wp_nonce_url( $url, $action, $name ) | Adds a nonce to a URL | Action links |
wp_verify_nonce( $nonce, $action ) | Returns 1 or 2 if valid, false if not | Custom checks |
check_admin_referer( $action, $name ) | Verifies, and stops with an error page if invalid | Admin form and link handlers |
check_ajax_referer( $action, $name ) | Verifies, and stops with -1 if invalid | AJAX handlers |
The default field name is _wpnonce. Use a specific action string for each operation, and include the object ID when the action targets one object.
Forms
Add the field inside the form:
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="myplugin_import_events">
<?php wp_nonce_field( 'myplugin_import_events' ); ?>
<?php submit_button( __( 'Import events', 'myplugin' ) ); ?>
</form>
Verify it in the handler, together with the capability:
<?php
add_action( 'admin_post_myplugin_import_events', 'myplugin_handle_import' );
function myplugin_handle_import(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to import events.', 'myplugin' ), '', array( 'response' => 403 ) );
}
check_admin_referer( 'myplugin_import_events' );
// Safe to run the import here.
wp_safe_redirect( admin_url( 'edit.php?post_type=myplugin_event&imported=1' ) );
exit;
}
check_admin_referer() ends the request with a "The link you followed has expired" page if the nonce is missing or invalid, so code after it only runs for valid requests.
If you need custom handling instead, call wp_verify_nonce() yourself. Sanitise the submitted value first:
<?php
$nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'myplugin_import_events' ) ) {
wp_die( esc_html__( 'Your session expired. Reload the page and try again.', 'myplugin' ), '', array( 'response' => 403 ) );
}
Action links
Links that change state, such as "Delete" or "Archive", need a nonce in the URL. Build the link with the object ID in the action:
<?php
$delete_url = wp_nonce_url(
admin_url( 'admin-post.php?action=myplugin_delete_event&event_id=' . $event_id ),
'myplugin_delete_event_' . $event_id
);
?>
<a href="<?php echo esc_url( $delete_url ); ?>"><?php esc_html_e( 'Delete', 'myplugin' ); ?></a>
The handler reads the ID, checks the capability for that event, then verifies the nonce for that same event:
<?php
add_action( 'admin_post_myplugin_delete_event', 'myplugin_handle_delete_event' );
function myplugin_handle_delete_event(): void {
$event_id = isset( $_GET['event_id'] ) ? absint( wp_unslash( $_GET['event_id'] ) ) : 0;
if ( ! $event_id || ! current_user_can( 'delete_post', $event_id ) ) {
wp_die( esc_html__( 'You are not allowed to delete this event.', 'myplugin' ), '', array( 'response' => 403 ) );
}
check_admin_referer( 'myplugin_delete_event_' . $event_id );
wp_trash_post( $event_id );
wp_safe_redirect( admin_url( 'edit.php?post_type=myplugin_event' ) );
exit;
}
Because the ID is part of the action, a nonce taken from the link for event 42 cannot be replayed against event 43.
Prefer POST for actions that change data. Links are convenient in admin tables, which is why core uses them, but browsers, extensions and crawlers can prefetch GET URLs. If you use a link, the nonce at least makes sure a prefetch from another site cannot trigger it.
AJAX with admin-ajax.php
The classic AJAX endpoint is admin-ajax.php. You register a handler on wp_ajax_{action} for logged-in users (and wp_ajax_nopriv_{action} for logged-out visitors, if the action is public).
Pass the URL and a nonce to your script:
<?php
add_action( 'wp_enqueue_scripts', 'myplugin_enqueue_favourites' );
function myplugin_enqueue_favourites(): void {
if ( ! is_singular( 'myplugin_event' ) || ! is_user_logged_in() ) {
return;
}
wp_enqueue_script(
'myplugin-favourites',
plugins_url( 'assets/js/favourites.js', __FILE__ ),
array(),
'1.0.0',
array( 'strategy' => 'defer' )
);
wp_add_inline_script(
'myplugin-favourites',
'const mypluginFavourites = ' . wp_json_encode(
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'myplugin_favourite' ),
'eventId' => get_the_ID(),
)
) . ';',
'before'
);
}
Send it with the request:
// assets/js/favourites.js
document.querySelector( '.myplugin-favourite' )?.addEventListener( 'click', async () => {
const body = new URLSearchParams( {
action: 'myplugin_favourite',
nonce: mypluginFavourites.nonce,
event_id: mypluginFavourites.eventId,
} );
const response = await fetch( mypluginFavourites.ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
body,
} );
const result = await response.json();
console.log( result.success ? 'Saved' : result.data.message );
} );
And verify it in the handler:
<?php
add_action( 'wp_ajax_myplugin_favourite', 'myplugin_ajax_favourite' );
function myplugin_ajax_favourite(): void {
check_ajax_referer( 'myplugin_favourite', 'nonce' );
if ( ! current_user_can( 'read' ) ) {
wp_send_json_error( array( 'message' => __( 'Not allowed.', 'myplugin' ) ), 403 );
}
$event_id = isset( $_POST['event_id'] ) ? absint( wp_unslash( $_POST['event_id'] ) ) : 0;
$event = get_post( $event_id );
if ( ! $event || 'myplugin_event' !== $event->post_type || 'publish' !== $event->post_status ) {
wp_send_json_error( array( 'message' => __( 'Event not found.', 'myplugin' ) ), 404 );
}
$user_id = get_current_user_id();
$favourites = get_user_meta( $user_id, 'myplugin_favourites', true );
$favourites = is_array( $favourites ) ? $favourites : array();
$favourites[] = $event_id;
$favourites = array_values( array_unique( $favourites ) );
update_user_meta( $user_id, 'myplugin_favourites', $favourites );
wp_send_json_success( array( 'count' => count( $favourites ) ) );
}
The second argument to check_ajax_referer() names the request field that holds the nonce. wp_send_json_success() and wp_send_json_error() send the JSON response and end the request. There is no wp_ajax_nopriv_ handler, so logged-out visitors cannot call this action at all.
REST API requests
For the REST API, do not create your own nonces. WordPress uses one special action, wp_rest:
<?php
$nonce = wp_create_nonce( 'wp_rest' );
JavaScript sends it in the X-WP-Nonce header, as in the custom endpoints lesson. WordPress verifies it before your route runs and uses it to decide whether the cookie counts:
- valid nonce: the request runs as the logged-in user;
- no nonce: the request runs as a logged-out visitor;
- invalid nonce: the request is rejected with a
403rest_cookie_invalid_nonceerror.
So the CSRF protection is built in, and your permission_callback does the capability check. The @wordpress/api-fetch package adds the header for you when it is configured with the nonce, which is how the block editor works.
For new interactive features, a REST endpoint is usually a better choice than admin-ajax.php: it has argument validation, permission callbacks and clear status codes built in.
Expired nonces
Because nonces expire, a user who leaves a form open overnight may see an error when they submit it. That is expected behaviour. Make the error message say what to do ("reload the page and try again") rather than showing a blank failure. The block editor refreshes its REST nonce automatically during long sessions.
Summary
- Nonces protect against CSRF by proving a request came from a page your site generated for this user and action.
- They are not single-use, they expire, and they are not authorisation: always pair them with
current_user_can(). - Use
wp_nonce_field()andcheck_admin_referer()for forms,wp_nonce_url()with the object ID in the action for links, andcheck_ajax_referer()for AJAX. - For REST, send a
wp_restnonce inX-WP-Nonce; WordPress verifies it and your permission callback checks capabilities. - Do not rely on nonces for logged-out visitors; use validation and spam protection there.