WordPress wp_nonce_url(): Protect Admin Action Links Against CSRF with GET Nonces

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".