WordPress nonces are cryptographic tokens that protect admin actions from cross-site request forgery (CSRF) — they verify that a request was intentionally initiated by an authenticated user from a legitimate page on the same site. Most developers are familiar with wp_nonce_field() (for POST forms) and wp_create_nonce() + check_ajax_referer() (for AJAX requests), but a third pattern — wp_nonce_url() — handles the case where the action is triggered by a GET link rather than a POST form. Examples include “Delete this item”, “Activate this plugin”, “Regenerate thumbnails” links in admin tables. Without a nonce, any page on any site can include an <img src="https://yoursite.com/wp-admin/...action=delete&id=42"> tag and the action would execute when an admin views that page. wp_nonce_url() appends a _wpnonce query parameter to any URL, and wp_verify_nonce() checks it server-side.
Problem: A custom admin list table has "Delete" and "Activate" action links for each row. These are plain GET links with the item ID in the URL. Without nonces, any third-party site can trigger the delete/activate action against an authenticated admin by loading the URL in an image tag.
Solution: Wrap each action URL with wp_nonce_url() when rendering the links. In the handler callback, verify with wp_verify_nonce() before processing the action and redirect using wp_safe_redirect().
<?php
// ── Build nonce-protected action URLs ─────────────────────────────────
function get_item_action_url( int $item_id, string $action ): string {
$base_url = add_query_arg( [
'page' => 'my-plugin-items',
'action' => $action,
'item_id' => $item_id,
], admin_url( 'admin.php' ) );
// wp_nonce_url() appends ?_wpnonce={nonce} to the URL
return wp_nonce_url( $base_url, "my_plugin_{$action}_{$item_id}" );
}
// ── Render action links in a table row ────────────────────────────────
function render_item_row( array $item ): void {
$delete_url = get_item_action_url( $item['id'], 'delete' );
$activate_url = get_item_action_url( $item['id'], 'activate' );
printf(
'<a href="%s" onclick="return confirm('Delete this item?')">Delete</a> | ',
esc_url( $delete_url )
);
printf(
'<a href="%s">Activate</a>',
esc_url( $activate_url )
);
}
// ── Handle the action on page load ────────────────────────────────────
add_action( 'admin_init', 'handle_item_actions' );
function handle_item_actions(): void {
$action = sanitize_key( $_GET['action'] ?? '' );
$item_id = absint( $_GET['item_id'] ?? 0 );
if ( ! $action || ! $item_id ) {
return;
}
// 1. Verify capability
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Forbidden.', 403 );
}
// 2. Verify nonce — wp_verify_nonce returns 1 (valid, recent) or 2 (valid, older half)
$nonce = sanitize_text_field( $_GET['_wpnonce'] ?? '' );
if ( ! wp_verify_nonce( $nonce, "my_plugin_{$action}_{$item_id}" ) ) {
wp_die( 'Security check failed. Please go back and try again.' );
}
// 3. Process action
if ( 'delete' === $action ) {
delete_item( $item_id );
$notice = 'deleted';
} elseif ( 'activate' === $action ) {
activate_item( $item_id );
$notice = 'activated';
} else {
return;
}
// 4. Redirect to clean URL with notice
wp_safe_redirect( add_query_arg( [
'page' => 'my-plugin-items',
'notice' => $notice,
], admin_url( 'admin.php' ) ) );
exit;
}
NOTE: WordPress nonces expire in 24 hours by default (two 12-hour windows — wp_verify_nonce() returns 1 for the first window and 2 for the second). This means a link generated by wp_nonce_url() stops working after 24 hours — this is intentional. If users bookmark admin action links or store them in email drafts for more than a day, the nonces will be invalid. For long-lived links, consider a different mechanism (a dedicated confirmation page with a fresh nonce generated on load). The nonce action string ("my_plugin_{$action}_{$item_id}") should be as specific as possible — include the action name and the target object ID so that a nonce for "delete item 42" cannot be reused to "delete item 43".