WordPress current_user_can(): Capability Checks in Admin Pages, AJAX, and REST Routes

Access control is one of the most important and most commonly implemented incorrectly aspects of WordPress plugin development. WordPress’s capability system is the correct tool for every access decision: checking whether the current user can view a piece of content, modify a post, access an admin page, or call a REST API endpoint. The current_user_can() function checks the current logged-in user against a capability string. But capabilities go deeper than a single function call — understanding the difference between primitive capabilities and meta capabilities, knowing which capabilities are checked by WordPress core and which you need to add to your own code, and applying checks correctly in different contexts (hooks, templates, REST routes, AJAX handlers) is what separates secure plugins from vulnerable ones. A missing capability check is the most common source of privilege escalation vulnerabilities in WordPress plugins.

Problem: Your plugin has an admin settings page, an AJAX handler, and a REST endpoint — all of which should only be accessible to administrators. Without explicit capability checks, any logged-in user can access them by crafting a direct request.

Solution: Add current_user_can() checks at every entry point: in add_menu_page(), at the top of AJAX callbacks, and in the 'permission_callback' of REST routes. Use the most specific capability that applies — prefer 'edit_post' with a post ID over 'edit_posts' for post-level checks.

<?php
// ── Admin page — capability check built into add_menu_page ───────────
add_action( 'admin_menu', function () {
    add_menu_page(
        'My Plugin Settings',
        'My Plugin',
        'manage_options',        // ← capability required to see the page
        'my-plugin-settings',
        'render_my_plugin_settings'
    );
} );

// ── Always check again inside the render callback ─────────────────────
// (user could navigate directly to the URL)
function render_my_plugin_settings() {
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( __( 'You do not have permission to access this page.', 'textdomain' ) );
    }
    // ... render settings form
}

// ── AJAX handler — check capability + nonce ────────────────────────────
add_action( 'wp_ajax_my_plugin_action', 'handle_my_plugin_ajax' );

function handle_my_plugin_ajax() {
    // 1. Verify nonce
    check_ajax_referer( 'my_plugin_nonce', 'nonce' );

    // 2. Check capability
    if ( ! current_user_can( 'edit_posts' ) ) {
        wp_send_json_error( [ 'message' => __( 'Permission denied.', 'textdomain' ) ], 403 );
    }

    // 3. Process
    wp_send_json_success( [ 'result' => 'ok' ] );
}

// ── REST API endpoint ─────────────────────────────────────────────────
register_rest_route( 'my-plugin/v1', '/settings', [
    'methods'             => [ 'GET', 'POST' ],
    'callback'            => 'my_plugin_rest_callback',
    'permission_callback' => function () {
        return current_user_can( 'manage_options' );  // ← required, not optional
    },
] );

// ── Meta capabilities: post-level checks ──────────────────────────────
// 'edit_post' checks whether THIS user can edit THIS specific post
// (respects authorship, custom post types, and filtered capabilities)
$post_id = 42;
if ( current_user_can( 'edit_post', $post_id ) ) {
    // safe to show edit UI
}
if ( current_user_can( 'delete_post', $post_id ) ) {
    // safe to show delete button
}

// ── Check capabilities for a SPECIFIC user (not the logged-in one) ────
$other_user = get_userdata( 5 );
if ( $other_user && $other_user->has_cap( 'manage_options' ) ) {
    // user ID 5 is an admin
}

// ── Common capability reference ────────────────────────────────────────
// 'read'                → any logged-in user
// 'edit_posts'          → author and above (for post type 'post')
// 'publish_posts'       → author and above
// 'edit_others_posts'   → editor and above
// 'manage_options'      → administrator only
// 'install_plugins'     → administrator only

NOTE: Never set 'permission_callback' => '__return_true' on a REST route that modifies data — this makes the endpoint publicly accessible to anyone, including unauthenticated users. '__return_true' is only appropriate for routes that return non-sensitive public data. For REST endpoints that should require authentication but have no specific capability requirement, use fn() => is_user_logged_in(). Also note that current_user_can() returns false for unauthenticated (not logged-in) users — you do not need a separate is_user_logged_in() check before calling it.