Capability & permission checks (authorization)
When to use this skill
Use this skill wherever code does something not everyone should be able to do:
- Admin menu/submenu pages and settings screens.
- AJAX (
wp_ajax_*) and RESTpermission_callbacks. admin-post.phphandlers and action links (delete/approve/activate).- Editing options, users, posts, taxonomies, files, or plugin state.
- Anything reading or writing another user's data.
Authorization answers "is this user allowed?" — distinct from the nonce, which
answers "did this request come from us?" (see nonces-csrf-protection). Use both.
Core principles (and why they matter)
- Check capabilities, not roles. Roles are bundles of capabilities that admins can
reassign.
current_user_can( 'administrator' )is a bug — test the capability (manage_options), so custom roles and multisite behave correctly. - Use the most specific capability.
manage_optionsfor settings,edit_poststo create posts,edit_post/delete_post(with an ID) for a specific post. Broad caps over-grant. - Authorization and CSRF are independent. A capability check without a nonce is CSRF-able; a nonce without a capability check lets any logged-in user act. Pair them.
- Re-check at every entry point. Hiding a menu item or button is UX, not security. The handler that does the work must check again — the URL/endpoint is directly reachable.
- Per-object checks need the object ID.
current_user_can( 'edit_post', $post_id )respects ownership and post-type rules;current_user_can( 'edit_posts' )alone does not. - Fail closed. Deny by default; only proceed when the check explicitly passes.
Step-by-step implementation
- Identify the operation and the capability it requires (use a meta cap with an ID for per-object actions).
- At the start of the handler, after the nonce check:
if ( ! current_user_can( $cap[, $object_id] ) ) { deny(); }. deny()=wp_die( ..., 403 ),wp_send_json_error( ..., 403 ), or a RESTWP_Errorwithrest_forbidden/ status 403.- For menus/REST, supply the capability where the API expects it
(
add_menu_pagecapability arg, RESTpermission_callback). - Never gate solely by hiding UI.
Supporting references
| Reference | Load when |
|---|---|
| Capability check examples | Implementing capability-gated admin pages, AJAX handlers, per-object actions, and REST callbacks. |
| Capability / authorization checklist | Before final verification of the capability / authorization controls. |
Common AI mistakes / anti-patterns
Mistake 1 — Checking the role instead of the capability
// ❌ Insecure: roles are mutable; this misses custom/multisite setups.
if ( in_array( 'administrator', wp_get_current_user()->roles, true ) ) {
do_admin_thing();
}
// ✅ Secure: test the capability.
if ( current_user_can( 'manage_options' ) ) {
do_admin_thing();
}
Mistake 2 — Relying on a hidden menu/button as the boundary
// ❌ Insecure: handler trusts that only admins saw the link.
add_action( 'admin_post_purge_cache', function () {
purge_cache(); // directly reachable via admin-post.php by anyone logged in
} );
// ✅ Secure: the handler re-checks nonce + capability.
add_action( 'admin_post_purge_cache', function () {
check_admin_referer( 'purge_cache' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Forbidden', 'my-plugin' ), 403 );
}
purge_cache();
} );
Mistake 3 — Generic capability where a per-object check is needed
// ❌ Insecure: any author can edit ANY post, not just their own.
if ( current_user_can( 'edit_posts' ) ) {
wp_update_post( array( 'ID' => $post_id, 'post_title' => $title ) );
}
// ✅ Secure: meta capability with the object ID enforces ownership.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
wp_die( esc_html__( 'You cannot edit this post.', 'my-plugin' ), 403 );
}
wp_update_post( array( 'ID' => $post_id, 'post_title' => $title ) );
Mistake 4 — REST route with no real permission callback
// ❌ Insecure: open to the world.
register_rest_route( 'my/v1', '/settings', array(
'methods' => 'POST',
'callback' => 'my_save',
'permission_callback' => '__return_true',
) );
// ✅ Secure: enforce the capability in permission_callback.
register_rest_route( 'my/v1', '/settings', array(
'methods' => 'POST',
'callback' => 'my_save',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
) );
Mistake 5 — Checking capability but never branching
// ❌ Insecure: result ignored; action runs regardless.
current_user_can( 'delete_users' );
delete_user_account( $id );
// ✅ Secure: branch and fail closed.
if ( ! current_user_can( 'delete_users' ) ) {
wp_die( esc_html__( 'Forbidden', 'my-plugin' ), 403 );
}
delete_user_account( $id );
Mistake 6 — Assigning roles or capabilities from request data
// ❌ Insecure: privilege escalation by posting role=administrator.
$user->set_role( $_POST['role'] );
// ✅ Secure: validate the role against a hardcoded allowlist.
$allowed_roles = array( 'subscriber', 'contributor', 'author' );
$role = isset( $_POST['role'] ) ? sanitize_key( wp_unslash( $_POST['role'] ) ) : '';
if ( ! in_array( $role, $allowed_roles, true ) ) {
wp_die( esc_html__( 'Invalid role.', 'my-plugin' ), 400 );
}
$user->set_role( $role );
Mistake 7 — Directly adding capabilities via user meta
// ❌ Insecure: grants arbitrary capabilities.
$user->add_cap( $_POST['capability'] );
// ✅ Secure: never add capabilities from input; use role-based allowlists.
$allowed_caps = array( 'edit_posts', 'publish_posts' );
$cap = isset( $_POST['capability'] ) ? sanitize_key( wp_unslash( $_POST['capability'] ) ) : '';
if ( ! in_array( $cap, $allowed_caps, true ) ) {
wp_die( esc_html__( 'Invalid capability.', 'my-plugin' ), 400 );
}
$user->add_cap( $cap );
Related: see the multisite-security skill for network vs. site scope and the
nonces-csrf-protection skill for pairing these checks with a nonce.
Correct code examples
A complete example covering an admin page, an AJAX handler, a per-object action, and a
REST permission callback is in
references/capability-check-example.php.
// Common capabilities and when to use them.
current_user_can( 'manage_options' ); // site settings / options
current_user_can( 'edit_posts' ); // create/edit posts (general)
current_user_can( 'edit_post', $post_id ); // edit THIS post (ownership-aware)
current_user_can( 'publish_posts' ); // publish
current_user_can( 'upload_files' ); // media uploads
current_user_can( 'edit_users' ); // manage other users
current_user_can( 'install_plugins' ); // install/activate plugins
Checklist
- Every privileged handler calls
current_user_can()and branches on the result. - Capabilities are tested, never roles.
- The most specific capability is used; per-object actions pass the object ID.
- The check is paired with a nonce verification on state-changing requests.
- Admin pages re-check the capability on render (not just at menu registration).
- REST routes define a real
permission_callback(never__return_truefor writes). - AJAX handlers check capability after
check_ajax_referer. - Denied requests fail closed with a 403 /
WP_Error.