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
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 point | Required guard | What happens without it |
|---|---|---|
| REST route | permission_callback returning true or false | Any request that passes authentication runs the callback |
admin-ajax.php | check_ajax_referer() plus current_user_can() | Any logged-in user can call the action, including nopriv hooks for visitors |
admin-post.php | check_admin_referer() plus current_user_can() | Any logged-in user can trigger the action; the menu capability never runs |
| Admin page callback | current_user_can() in the callback | Reachable by direct URL even when the menu item is hidden |
admin_init handler | current_user_can() before any side effect | Runs 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
- Grep the codebase for
register_rest_routeand confirm every entry has apermission_callback. Read what each one returns;'__return_true'is a decision, not a default. - List every
wp_ajax_hook and everyadmin_post_hook in the plugin, then confirm each handler callscurrent_user_can()itself. - 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. - 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.
- 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_optionsto 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