Roles vs Capabilities: The Two-Layer Model
WordPress access control is built on two concepts that work together. A capability is a single permission — a key that unlocks one specific action. A role is a named bundle of capabilities assigned to a user.
| Layer | What it is | Examples |
|---|---|---|
| Capability | One permission — can do a specific thing | edit_posts, manage_options, upload_files, publish_posts |
| Role | Named bundle of capabilities | Administrator, Editor, Author, Contributor, Subscriber |
| Meta capability | Capability checked against a specific object | edit_post (for post #123), delete_post (for post #456) |
Roles are stored in wp_options under wp_user_roles. When you call add_role() or add_cap(), that row is updated in the database — which is why these calls belong in a plugin activation hook, not in code that runs on every request.
current_user_can(): Your Gatekeeper
current_user_can() is the only secure way to gate access in WordPress. Never hide a feature by checking $user->roles directly — roles are just bundles; the actual permission check must go through capabilities. Pass either a primitive capability or a meta capability with an object ID:
// Gate any feature behind a capability check
if ( current_user_can( 'manage_my_plugin_settings' ) ) {
show_plugin_settings_page();
} else {
wp_die( __( 'You do not have permission to access this page.', 'my-plugin' ) );
}
// Check a capability for a specific post (meta capability)
$post_id = 123;
if ( current_user_can( 'edit_post', $post_id ) ) {
show_edit_button( $post_id );
}
// Check for a different user (not the current one)
$user_id = 42;
$user = new WP_User( $user_id );
if ( $user->has_cap( 'edit_others_posts' ) ) {
// User 42 can edit others' posts
}
For meta capabilities like edit_post, WordPress resolves them against the specific object: it checks whether the post exists, whether the user authored it, whether the user has edit_others_posts, and so on. You never need to write that logic yourself for built-in post types — WordPress handles the mapping. For custom meta capabilities, you wire this up via the map_meta_cap filter (covered below).
Creating a Custom Role with add_role()
Use add_role() to define a new role with its own set of capabilities. Run it in register_activation_hook() — not on init — so it writes to the database once on plugin activation rather than on every page load. add_role() is idempotent: if the role already exists, it does nothing.
/**
* Register a 'Project Manager' role on plugin activation.
* add_role() is idempotent — safe to call even if the role exists.
*/
function myplugin_register_roles(): void {
add_role(
'project_manager',
__( 'Project Manager', 'my-plugin' ),
[
'read' => true,
'edit_posts' => false,
'upload_files' => true,
'edit_projects' => true, // custom capability
'view_project_reports' => true, // custom capability
]
);
}
register_activation_hook( __FILE__, 'myplugin_register_roles' );
Custom capabilities like edit_projects and view_project_reports don’t need to be registered anywhere — they’re just strings. Their meaning is defined by where you check them with current_user_can(). Prefix custom capabilities with your plugin slug to avoid collisions with other plugins.
Adding Capabilities to Existing Roles
When you don’t need a new role but want to extend what an existing role can do, use get_role() and add_cap(). Again, this belongs in the activation hook — capabilities are persisted to the database and don’t need to be re-added on every request:
/**
* Grant 'manage_products' to admins and editors.
* Run on activation — capabilities are stored in the DB, not re-run on every request.
*/
function myplugin_add_capabilities(): void {
$roles_to_grant = [ 'administrator', 'editor' ];
foreach ( $roles_to_grant as $role_slug ) {
$role = get_role( $role_slug );
if ( $role ) {
$role->add_cap( 'manage_products' );
$role->add_cap( 'view_product_reports' );
}
}
}
register_activation_hook( __FILE__, 'myplugin_add_capabilities' );
Custom Post Type Capabilities
When you register a custom post type, WordPress can generate a full set of capabilities for it automatically. Set capability_type to your CPT slug and map_meta_cap to true, then WordPress generates edit_project, edit_projects, edit_others_projects, and the rest from your capabilities map. See the custom post types guide for the full registration setup — the capabilities argument sits alongside the labels and rewrite config:
register_post_type( 'project', [
'labels' => [ 'name' => __( 'Projects', 'my-plugin' ) ],
'public' => true,
'capability_type' => 'project', // generates edit_project, delete_project, etc.
'map_meta_cap' => true, // let WP resolve meta caps to primitives
'capabilities' => [
'edit_post' => 'edit_project',
'delete_post' => 'delete_project',
'read_post' => 'read_project',
'edit_posts' => 'edit_projects',
'edit_others_posts' => 'edit_others_projects',
'publish_posts' => 'publish_projects',
'read_private_posts' => 'read_private_projects',
'delete_posts' => 'delete_projects',
'create_posts' => 'create_projects',
],
] );
// Grant the generated capabilities to admins on activation
function myplugin_grant_project_caps(): void {
$admin = get_role( 'administrator' );
$caps = [
'edit_project', 'delete_project', 'read_project',
'edit_projects', 'edit_others_projects', 'publish_projects',
'read_private_projects', 'delete_projects', 'create_projects',
];
foreach ( $caps as $cap ) {
$admin->add_cap( $cap );
}
}
register_activation_hook( __FILE__, 'myplugin_grant_project_caps' );
By granting these to administrator on activation and selectively granting subsets to other roles (e.g. a project_manager gets edit_projects but not delete_projects), you get granular CPT-level access control without writing custom permission logic.
The map_meta_cap Filter: Custom Meta Capabilities
Meta capabilities like edit_post are resolved to primitive capabilities by WordPress’s map_meta_cap() function. For your own custom meta capabilities — ones that depend on the object being checked — hook into the map_meta_cap filter to define the resolution logic:
// Map the meta capability 'view_project_report' to primitive capabilities
add_filter( 'map_meta_cap', function( array $caps, string $cap, int $user_id, array $args ): array {
if ( 'view_project_report' !== $cap ) {
return $caps;
}
$project_id = (int) ( $args[0] ?? 0 );
$project = get_post( $project_id );
if ( ! $project ) {
return [ 'do_not_allow' ];
}
// The project author can always view their own report
if ( (int) $project->post_author === $user_id ) {
return [ 'read' ];
}
// Everyone else needs the 'view_project_reports' primitive capability
return [ 'view_project_reports' ];
}, 10, 4 );
// Usage — WP resolves 'view_project_report' → 'read' or 'view_project_reports' automatically
if ( current_user_can( 'view_project_report', $project_id ) ) {
render_project_report( $project_id );
}
The filter receives the capability name and the args array (which contains the object ID). Return an array of primitive capability slugs that the user must have. Return [ 'do_not_allow' ] to block access unconditionally. This keeps all your permission logic in one place rather than scattered across current_user_can() calls.
REST API Endpoint Permission Callbacks
Every register_rest_route() call should include a permission_callback. Without it (or with __return_true), the endpoint is publicly accessible to anyone. Use current_user_can() in the callback with the appropriate capability — including meta capabilities for object-specific writes. The custom REST API endpoints guide covers the full route registration pattern; here’s the capability layer:
// Require 'edit_projects' to list all projects
register_rest_route( 'myplugin/v1', '/projects', [
'methods' => 'GET',
'callback' => 'myplugin_get_projects',
'permission_callback' => fn() => current_user_can( 'edit_projects' ),
] );
// Require 'edit_project' on the specific project for updates
register_rest_route( 'myplugin/v1', '/projects/(?P<id>\d+)', [
'methods' => [ 'PUT', 'PATCH' ],
'callback' => 'myplugin_update_project',
'permission_callback' => function( WP_REST_Request $request ): bool {
$project_id = (int) $request->get_param( 'id' );
return current_user_can( 'edit_project', $project_id );
},
'args' => [
'id' => [
'validate_callback' => fn( $v ) => is_numeric( $v ),
],
],
] );
A permission_callback that returns false causes WordPress to return a 401 (unauthenticated) or 403 (forbidden) response before the callback function ever runs — there’s no risk of the callback leaking data on a failed auth check.
Restricting wp-admin Menus by Capability
Menu removal hides a link — it does not block access. Always pair remove_menu_page() with a server-side capability check on admin_init that calls wp_die() if the user navigates directly to the URL:
// Remove wp-admin menu pages for users without specific capabilities
add_action( 'admin_menu', function(): void {
// Non-admins cannot see Settings or Plugins menus
if ( ! current_user_can( 'manage_options' ) ) {
remove_menu_page( 'options-general.php' );
remove_menu_page( 'plugins.php' );
}
// Only grant access to the Project Reports page if the user has the cap
if ( ! current_user_can( 'view_project_reports' ) ) {
remove_submenu_page( 'myplugin-menu', 'myplugin-reports' );
}
} );
// Always check the capability server-side on the page load itself — menu removal
// only hides the link, it does NOT block direct URL access
add_action( 'admin_init', function(): void {
$screen = get_current_screen();
if ( $screen && 'myplugin-reports' === $screen->id ) {
if ( ! current_user_can( 'view_project_reports' ) ) {
wp_die( __( 'You do not have permission to view this page.', 'my-plugin' ) );
}
}
} );
Removing Roles and Capabilities on Deactivation
Clean up roles and capabilities in the deactivation hook. Leaving orphaned roles in the database is harmless but untidy; leaving orphaned custom capabilities on built-in roles can create confusion if another plugin later checks for them:
// Clean up roles and capabilities on plugin deactivation
function myplugin_cleanup_roles(): void {
// Remove the custom role entirely
remove_role( 'project_manager' );
// Remove custom capabilities from built-in roles
foreach ( [ 'administrator', 'editor' ] as $role_slug ) {
$role = get_role( $role_slug );
if ( $role ) {
$role->remove_cap( 'manage_products' );
$role->remove_cap( 'view_product_reports' );
}
}
}
register_deactivation_hook( __FILE__, 'myplugin_cleanup_roles' );
User Roles and Capabilities: Quick-Start Checklist
- Always check permissions with
current_user_can()— never by checking$user->rolesdirectly - Use
add_role()andadd_cap()inregister_activation_hook(), not oninit - Prefix all custom capability slugs with your plugin prefix (e.g.
myplugin_view_reports) - For custom post types, set
capability_typeandmap_meta_cap => trueinregister_post_type() - Grant the generated CPT capabilities to the appropriate roles on activation
- Use the
map_meta_capfilter for capabilities that depend on a specific object (post, user, order) - Add a
permission_callbackto everyregister_rest_route()call — never use__return_truein production - Pair
remove_menu_page()with a server-sideadmin_initcheck — menu removal alone does not block URL access - Follow the principle of least privilege: grant the minimum capabilities each role needs
- Remove custom roles and capabilities in
register_deactivation_hook()to keep the database clean
Roles and capabilities cover who can do what. The complementary question is what data they can input safely — always sanitize and validate user input on any form or REST endpoint your custom roles interact with. For the broader attack surface beyond access control, proactive WordPress security practices covers hardening, monitoring, and the security measures that work alongside capability checks to keep a site locked down.


