WordPress Nonce System: Implementation and CSRF Protection Best Practices

WordPress nonces (“numbers used once”) are cryptographic tokens that protect forms and URL-based actions against Cross-Site Request Forgery (CSRF) attacks — when a nonce is required, an attacker cannot forge a valid request on behalf of a logged-in user because the attacker’s page has no way to obtain the current nonce value for that user, site, and action combination. A WordPress nonce is generated by wp_create_nonce( 'action-name' ) and verified by wp_verify_nonce( $nonce_value, 'action-name' ) — the return value is 1 if the nonce is valid and less than 12 hours old, 2 if it is valid but more than 12 hours old (the second half of the 24-hour nonce lifespan), and false if invalid. WordPress nonces are not true cryptographic one-time tokens — they are valid for 24 hours and can be reused within that window — but they include the user ID, session token (from wp_get_session_token()), and a timestamp-based tick in the HMAC-SHA256 hash, making them user-specific and session-bound. Forms use wp_nonce_field( 'my-action', '_wpnonce' ) which outputs a hidden input field, while URL-based actions use wp_nonce_url( $url, 'my-action' ) which appends _wpnonce as a query parameter. AJAX requests in WordPress should pass the nonce in the request data and verify it in the AJAX handler with check_ajax_referer( 'my-action', 'nonce_field_name' ) — this function calls wp_die() automatically on failure, cleanly aborting the handler. REST API endpoints use wp_rest_request_nonce() and the X-WP-Nonce header pattern — the REST API infrastructure validates this header automatically for authenticated requests. Nonce lifespan can be extended or shortened via the nonce_life filter (return value in seconds) — a 4-hour lifespan is appropriate for sensitive admin actions, a 12-hour lifespan for front-end forms where users may leave tabs open. The SQL injection/XSS post covers input sanitization; nonces cover the CSRF layer of the WordPress security model.

Problem: A WordPress plugin adds a “Delete all user data” button to the admin profile page — clicking it sends a POST request to an admin-ajax.php handler that deletes the user’s posts and meta. An attacker embeds an auto-submitting form on their site that targets the same endpoint — any admin who visits the attacker’s page while logged in silently loses their data.

Solution: Add a nonce to the form and the AJAX handler, verify the nonce before executing any data modification, and check the current user’s capability — three lines of security code that block the CSRF attack entirely.

// ── 1. Output form with nonce field ────────────
add_action( 'show_user_profile', 'myplugin_render_delete_button' );
add_action( 'edit_user_profile', 'myplugin_render_delete_button' );

function myplugin_render_delete_button( WP_User $user ): void {
    // Only show to the user themselves or admins
    if ( ! current_user_can( 'edit_user', $user->ID ) ) return;
    ?>
    <table class="form-table">
        <tr>
            <th><?php esc_html_e( 'Data Management', 'myplugin' ); ?></th>
            <td>
                <form method="post" id="myplugin-delete-form">
                    <?php wp_nonce_field( 'myplugin_delete_data_' . $user->ID, '_myplugin_nonce' ); ?>
                    <input type="hidden" name="user_id" value="<?php echo esc_attr( $user->ID ); ?>">
                    <button type="button" id="myplugin-delete-btn" class="button button-secondary">
                        <?php esc_html_e( 'Delete All My Data', 'myplugin' ); ?>
                    </button>
                </form>
            </td>
        </tr>
    </table>
    <script>
    document.getElementById('myplugin-delete-btn').addEventListener('click', function() {
        if (!confirm('<?php echo esc_js( __('Are you sure? This cannot be undone.', 'myplugin') ); ?>')) return;
        const form  = document.getElementById('myplugin-delete-form');
        const data  = new FormData(form);
        data.append('action', 'myplugin_delete_user_data');
        fetch(ajaxurl, { method: 'POST', body: data })
            .then(r => r.json())
            .then(d => d.success ? location.reload() : alert(d.data));
    });
    </script>
    <?php
}

// ── 2. AJAX handler with nonce and capability checks ────────────
add_action( 'wp_ajax_myplugin_delete_user_data', 'myplugin_handle_delete_user_data' );

function myplugin_handle_delete_user_data(): void {
    $user_id = absint( $_POST['user_id'] ?? 0 );

    // Verify nonce (dies with -1 on failure)
    check_ajax_referer( 'myplugin_delete_data_' . $user_id, '_myplugin_nonce' );

    // Verify capability
    if ( ! current_user_can( 'edit_user', $user_id ) ) {
        wp_send_json_error( __( 'You do not have permission to do this.', 'myplugin' ), 403 );
    }

    // Perform the deletion — only reached if nonce and capability check pass
    $deleted = myplugin_delete_all_user_data( $user_id );
    if ( $deleted ) {
        wp_send_json_success( __( 'All data deleted.', 'myplugin' ) );
    } else {
        wp_send_json_error( __( 'Deletion failed. Please try again.', 'myplugin' ), 500 );
    }
}

// ── 3. REST API endpoint with nonce via X-WP-Nonce header ────────────
add_action( 'rest_api_init', function(): void {
    register_rest_route( 'myplugin/v1', '/user-data/(?P<id>\d+)', [
        'methods'             => 'DELETE',
        'permission_callback' => fn( WP_REST_Request $r ) => current_user_can( 'edit_user', (int) $r['id'] ),
        'callback'            => fn( WP_REST_Request $r ) => myplugin_delete_all_user_data( (int) $r['id'] )
            ? new WP_REST_Response( null, 204 )
            : new WP_Error( 'delete_failed', 'Deletion failed', [ 'status' => 500 ] ),
    ] );
} );
// REST API automatically validates X-WP-Nonce header for logged-in users;
// use wp_create_nonce('wp_rest') in the frontend JS and pass it as X-WP-Nonce.

NOTE: check_ajax_referer() and wp_verify_nonce() are not equivalent — check_ajax_referer() also checks the HTTP Referer header against allowed origins and calls wp_die() on failure, making it more suitable for AJAX handlers. For non-AJAX contexts where you want to handle the failure gracefully rather than dying, use wp_verify_nonce() and handle the false return yourself. Never use nonces as authentication tokens — they only verify that the request originated from a form rendered by your site for a specific logged-in user; they do not replace capability checks.