14+ years building on WordPress / Replies in under 5 hours
Security 9 min read · Updated July 2026

Building Secure & Granular Access Control in WordPress

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
Building Secure & Granular Access Control in WordPress
TL;DR

WordPress access control has two layers: capabilities (individual permissions like edit_posts) and roles (bundles like Editor). Use add_role() on register_activation_hook() to create custom roles, add_cap() to extend existing ones, and current_user_can() to check permissions in code — never $user->roles. For custom post types, set capability_type and map_meta_cap => true in register_post_type(). For custom meta capabilities that depend on a specific object, use the map_meta_cap filter. Every REST API endpoint needs a permission_callback. Always pair menu removal with a server-side admin_init check.

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

  1. Always check permissions with current_user_can() — never by checking $user->roles directly
  2. Use add_role() and add_cap() in register_activation_hook(), not on init
  3. Prefix all custom capability slugs with your plugin prefix (e.g. myplugin_view_reports)
  4. For custom post types, set capability_type and map_meta_cap => true in register_post_type()
  5. Grant the generated CPT capabilities to the appropriate roles on activation
  6. Use the map_meta_cap filter for capabilities that depend on a specific object (post, user, order)
  7. Add a permission_callback to every register_rest_route() call — never use __return_true in production
  8. Pair remove_menu_page() with a server-side admin_init check — menu removal alone does not block URL access
  9. Follow the principle of least privilege: grant the minimum capabilities each role needs
  10. 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.

Frequently asked questions

A capability is a single permission — a string like 'edit_posts' or 'manage_options' that grants the right to perform one specific action. A role is a named bundle of capabilities assigned to users — 'Editor', 'Author', or a custom role like 'Project Manager'. When you assign a user a role, they automatically get all the capabilities bundled in that role. You check permissions in code with current_user_can('capability_name'), which checks the user's capabilities regardless of which role granted them. Never check $user->roles directly — roles are just a delivery mechanism; capabilities are the actual permission.

Use add_role() inside register_activation_hook() in your plugin. Pass a slug (lowercase, underscores), a display name, and an array of capabilities. Example: add_role('project_manager', 'Project Manager', ['read' => true, 'edit_projects' => true, 'view_project_reports' => true]). WordPress stores roles in the wp_options table under wp_user_roles, so add_role() should run on activation — not on every page load via init. The function is idempotent: calling add_role() with a slug that already exists has no effect, so it's safe to call even if the role was registered in a previous activation.

A primitive capability is a simple boolean permission stored on a role — 'edit_posts', 'upload_files', 'manage_options'. It's either granted or it isn't. A meta capability is a higher-level capability that WordPress resolves against a specific object at check time — 'edit_post' (can the user edit post #123?), 'delete_post' (can they delete post #456?). When you call current_user_can('edit_post', 123), WordPress runs map_meta_cap() to resolve 'edit_post' into the relevant primitive capabilities (edit_posts, edit_others_posts, etc.), taking into account who authored the post and what its status is.

Use add_role() when you need an entirely new user type with its own set of capabilities — like a 'Project Manager' or 'Store Cashier' that doesn't fit any built-in role. Use add_cap() when you want to extend an existing role with additional capabilities — like giving Editors access to a 'manage_products' capability you've defined for a plugin. In both cases, run these calls in register_activation_hook(), not on a hook that fires on every request. Capabilities and roles are stored in the database, so writing them on every page load is both wasteful and a source of subtle bugs.

Use the permission_callback parameter in register_rest_route(). The callback receives the WP_REST_Request object and must return true (access granted) or false/WP_Error (access denied). Inside it, call current_user_can() with the appropriate capability: 'permission_callback' => fn() => current_user_can('edit_projects'). For endpoints that act on a specific object, use a meta capability with the object ID: 'permission_callback' => fn($r) => current_user_can('edit_project', (int)$r->get_param('id')). Never set permission_callback to '__return_true' in production — that makes the endpoint publicly accessible to unauthenticated requests.

Set capability_type to your CPT slug and map_meta_cap to true when calling register_post_type(). WordPress generates a full set of capabilities (edit_project, edit_projects, edit_others_projects, publish_projects, delete_projects, etc.) from the capability_type slug and maps meta capabilities like 'edit_post' to these generated primitives automatically. You then need to explicitly grant these generated capabilities to the appropriate roles in register_activation_hook() using get_role('administrator')->add_cap('edit_projects'), and so on. Without granting them, even administrators cannot access the CPT through the generated capabilities.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →