Skip to content
System nominalUTC --:--:--Articles 041

The Capability Checks People Forget at Three Entry Points

REST routes, admin-ajax handlers and admin-post actions each need their own guard. Here is the check each one requires and why the admin menu is not protection.

Tomas Lindqvist · @tlindqvist

Hardening and operations

Topic
SEC
Section
Security & Hardening
Published
15 SEP 2026
Reading time
7 MIN
Words
1959W
Technical cover graphic for The Capability Checks People Forget at Three Entry Points
Generated schematic — SEC series, REV 2.6.

A handler that quietly processes a request for any logged-in user is not a vulnerability you notice in testing, because your test account is an administrator and the handler works exactly as intended. It surfaces later, usually in an access log, when a low-privilege account on the site starts calling the same URL with different parameters.

The pattern is consistent across the three entry points where this happens: admin-ajax.php actions, admin-post.php actions, and REST routes registered with register_rest_route(). All three are reachable without ever rendering the admin screen that the developer assumed was the front door. The screen checks a capability to decide whether to draw a menu item. The handler is a separate request, and it inherits nothing from that check.

I audited a plugin set on WordPress 6.6.2, PHP 8.2, with a single administrator and two editor accounts, on a staging clone of a production site. Nine handlers across four plugins were reachable by the editor accounts. Two of them wrote options. One of those two wrote to a key the front-end template read directly.

All nine were reachable for the same dull reason: the handler never asked who was calling.

The registration code usually did check something. The menu page that linked to the handler had been given a capability, and the developer read that as protection for the whole feature. It only protects the menu.

What each entry point actually guards, and what it does not

The reason these bugs cluster is that none of the three entry points refuses an unguarded request by default. REST skips the permission check when the argument is absent, and the two admin endpoints hand your hook whatever arrived at the URL. Nothing in the request pipeline asks whether the caller was allowed to be there.

Entry pointRequired guardWhat happens without it
REST routepermission_callback returning true or falseAny request that passes authentication runs the callback
admin-ajax.phpcheck_ajax_referer() plus current_user_can()Any logged-in user can call the action, including nopriv hooks for visitors
admin-post.phpcheck_admin_referer() plus current_user_can()Any logged-in user can trigger the action; the menu capability never runs
Admin page callbackcurrent_user_can() in the callbackReachable by direct URL even when the menu item is hidden
admin_init handlercurrent_user_can() before any side effectRuns on every admin request that matches, for anyone who gets there

The admin page row is the one people argue about. Hiding a menu item with a capability argument in add_submenu_page() does prevent the item from appearing, and it also prevents the page callback from running through normal navigation. It does not prevent a typed URL from reaching the registered page callback, and it does nothing at all for an action handler that posts to a different file. If your handler is a form target, the guard belongs in the handler.

is_user_logged_in() is not an authorisation check

This is the single substitution that causes most of what I find, and it is an easy one to make because the function name sounds like a security decision. is_user_logged_in() returns a boolean about session state. It says a user is present. It says nothing about what that user may do, and on a site with a subscriber role, or with a membership plugin handing out accounts, "logged in" is a very low bar.

// Wrong: any logged-in account, including a subscriber.
if ( ! is_user_logged_in() ) {
    wp_send_json_error( 'Not allowed', 403 );
}

// Right: the specific capability this action needs.
if ( ! current_user_can( 'edit_others_posts' ) ) {
    wp_send_json_error( 'Not allowed', 403 );
}

current_user_can() takes a capability name, and for anything object-shaped it takes the object ID as a second argument. Passing an ID is what triggers the meta capability lookup: current_user_can( 'edit_post', $post_id ) is not a check that the user has a capability literally named edit_post. That name is a meta capability, and map_meta_cap() resolves it at runtime into the primitive capabilities that apply to that particular post, based on who wrote it, its status, and the role maps. That resolution is the entire reason object level checks work.

The practical consequence: current_user_can( 'edit_post' ) with no object gives map_meta_cap() nothing to resolve, so the check falls back to a plain role comparison and answers a different question than the one you meant to ask. Drop the second argument and the check stops being about that post. Write it the way the object demands it.

Version scope

The meta capability system and the map_meta_cap filter have been stable for many releases, and the resolution order described here reflects WordPress 6.x. Register a meta capability through map_meta_cap when your plugin introduces a new object type, and test the denial path as carefully as the allow path.

A missing permission_callback fails open, and core only warns you

REST is the one entry point where the framework could refuse to run an unguarded route, and it does not. Dispatch checks permissions only when a callback exists: the internal request handler tests whether permission_callback is set, and when it is empty the check is skipped and the route callback runs. The route is public in the most literal sense, reachable by any request that gets that far.

What core does instead is complain. Since WordPress 5.5, register_rest_route() fires a _doing_it_wrong() notice when a route group has no permission_callback, with a message saying the argument is missing and naming __return_true as the thing to use for routes that really are meant to be public. On a REST request under WP_DEBUG, that notice arrives as the X-WP-DoingItWrong response header. That header is the fastest way to find this bug on a site you control: call the endpoint with debug on and read the response headers before you write any code.

Where a global policy can still help

rest_pre_dispatch runs before the permission callback is consulted, so a filter on it can refuse a request across every registered route. It is the right hook for an IP allowlist or a maintenance block. It is the wrong place to implement a per-route authorisation rule, because it runs before the route has checked anything and it has no knowledge of the capability that route needs.

The correct shape is short. The callback checks the current user against the capability the operation needs, including the object where one exists, and returns a WP_Error rather than a bare false when the request should be refused with an explanation.

add_action( 'rest_api_init', function () {
    register_rest_route( 'myplugin/v1', '/posts/(?P<id>\d+)/note', array(
        'methods'             => 'POST',
        'callback'            => 'myplugin_save_note',
        'permission_callback' => 'myplugin_can_save_note',
        'args'                => array(
            'id'   => array( 'required' => true, 'sanitize_callback' => 'absint' ),
            'note' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field' ),
        ),
    ) );
} );

function myplugin_can_save_note( WP_REST_Request $request ) {
    $post_id = absint( $request['id'] );

    // Object level: resolves through map_meta_cap() for this specific post.
    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return new WP_Error(
            'myplugin_forbidden',
            'You cannot edit this post.',
            array( 'status' => rest_authorization_required_code() )
        );
    }

    return true;
}

Two details in that snippet are worth calling out. rest_authorization_required_code() returns 401 for a logged-out user and 403 for a logged-in user without the capability, which is the distinction clients actually need. And returning a WP_Error with a status key in its data produces a structured error response instead of the generic failure, so the client can tell an authorisation problem apart from a validation problem.

admin-ajax and admin-post need the check written by hand

Neither file checks anything for you. admin-ajax.php maps the action parameter to a hook and calls it, whether or not the visitor is logged in, which is what the wp_ajax_nopriv_ prefix exists for. admin-post.php does the same for admin_post_{$action} and requires an admin context, so unauthenticated callers get redirected to the login screen, but every logged-in account reaches the handler.

The nonce comes first because it establishes that the request was intended, and the capability check comes second because it establishes that the user is allowed to make it. A nonce alone is not authorisation: it is tied to a session and an action name, and it proves nothing about what the holder may do.

add_action( 'wp_ajax_myplugin_sync', 'myplugin_handle_sync' );

function myplugin_handle_sync() {
    // Nonce first: proves the request came from our screen.
    // The third argument is $stop. Passing false stops check_ajax_referer()
    // from dying with -1, so the handler can answer with a JSON error instead.
    if ( ! check_ajax_referer( 'myplugin_sync', 'nonce', false ) ) {
        wp_send_json_error( 'Invalid request.', 403 );
    }

    // Capability second: proves this user may perform the operation.
    if ( ! current_user_can( 'manage_woocommerce' ) ) {
        wp_send_json_error( 'Not allowed.', 403 );
    }

    $order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;

    if ( ! $order_id ) {
        wp_send_json_error( 'Unknown order.', 404 );
    }

    wp_send_json_success( array( 'order_id' => $order_id ) );
}

Note the second argument to wp_send_json_error(). It is the HTTP status code, not the message, and WordPress 4.7 added it. A handler that passes the message twice, or that leaves the status out and returns a 200 with an error body, will look like it is failing safe while telling every client that the request succeeded.

The admin-post.php version is the same shape with a different nonce function. Use check_admin_referer( 'myplugin_action' ), then current_user_can(), then sanitise, then wp_safe_redirect() back to a screen the user can see. Do not skip the capability check because the action name is obscure or because the form is only linked from a screen that requires manage_options. Obscurity is not a guard, and the link is not the handler.

manage_options is a sledgehammer, and most actions are not nails

When a developer is unsure which capability to require, the answer defaults to manage_options, because it is the one that works for an administrator and the only account they tested with. It is also the capability that gates most of the site's settings, so requiring it for "save the widget cache" hands every administrator-level account the ability to do something the operation never needed.

Choose the narrowest capability that matches the operation. Content reads map to edit_posts or a taxonomy capability. File writes map to upload_files. Site-wide settings genuinely map to manage_options, and there are fewer of those than the code suggests. If a plugin defines its own capability, use that one and grant it deliberately, rather than borrowing a core capability that carries more authority than the action requires.

This is also where audit logging earns its place, and where the advice in this article runs out. A capability check answers whether the request was permitted. It cannot answer whether the permitted request should have happened. A compromised administrator account, a stolen session, or a support user who was granted the role last month will pass every check in this article, and the only evidence left is a log. Record the user ID, the action, the object ID and the outcome for anything that changes state, and put the log where the person who can act on it will read it.

What to do on Monday

  1. Grep the codebase for register_rest_route and confirm every entry has a permission_callback. Read what each one returns; '__return_true' is a decision, not a default.
  2. List every wp_ajax_ hook and every admin_post_ hook in the plugin, then confirm each handler calls current_user_can() itself.
  3. Search for is_user_logged_in() and check each occurrence. Some are redirects for logged-out visitors and are fine. Any that precede a write need replacing.
  4. Re-run the front-end and admin paths with an editor or subscriber account, not an administrator. A handler that answers correctly for the wrong user is the whole bug.
  5. Pick one capability per handler and write it in a comment above the check. The next person to edit that handler will otherwise widen it to manage_options to make a test pass.

Tomas Lindqvist · @tlindqvist

Hardening and operations

Threat models plugins, audits capability and nonce handling, and builds the observability that tells you an integration broke before a customer does.

Continue

Related in Security & Hardening