Skip to main content
Security alertPHP2Shell: critical PHP flaw — WordPress updates can't fix itCheck your PHP version
WP HealthKit

WordPress REST API Permissions: User Capability Checks

October 2, 202617 min readSecurityBy Jamie

Table of Contents

  1. REST API Permissions Foundation
  2. Default Capability Patterns
  3. Custom Permission Callbacks
  4. Contextual Permissions for Resources
  5. Custom Capabilities Architecture
  6. REST API Permissions for Custom Post Types
  7. Taxonomy Permissions
  8. User Data Access Control
  9. FAQ

REST API Permissions Foundation

WordPress REST API permissions control who can access endpoints and what data they can modify. Without proper permission_callback configuration, your REST endpoints become security vulnerabilities. Any user (even unauthenticated ones) might access sensitive data or trigger destructive actions.

REST API permissions consist of two layers: authentication (verifying who you are) and authorization (verifying what you can do). Both are essential. A user might be authenticated but lack permissions for a specific action.

The WordPress REST API permissions pattern requires defining a permission_callback for every endpoint:

<?php
// Register REST endpoint with permission callback
register_rest_route('my-plugin/v1', '/posts', [
    'methods' => 'GET',
    'callback' => 'my_plugin_get_posts',
    'permission_callback' => function($request) {
        // This callback determines who can access the endpoint
        return current_user_can('read_posts');
    },
]);

If you omit permission_callback, WordPress defaults to no access for unauthenticated users and full access for admins. This is generally permissive and dangerous.

WP HealthKit audits REST API endpoints for permission configuration, identifying endpoints without callbacks, overly permissive callbacks, and missing capability checks.

Default Capability Patterns

WordPress provides built-in capabilities mapped to standard operations. Learn these before customizing:

<?php
// Standard REST API capabilities by operation
$capability_patterns = [
    'edit_posts' => 'Can edit any post',
    'edit_others_posts' => 'Can edit other users\' posts',
    'publish_posts' => 'Can publish posts',
    'delete_posts' => 'Can delete posts',
    'manage_options' => 'Can manage site options',
    'read' => 'Can read (basic access)',
];

// These capabilities are role-dependent
// Admin: Has all capabilities
// Editor: edit_posts, edit_others_posts, publish_posts, delete_posts
// Author: edit_posts, publish_posts (own only)
// Contributor: edit_posts (own only)
// Subscriber: read

// Use default capabilities for standard operations
register_rest_route('my-plugin/v1', '/posts', [
    'methods' => 'GET',
    'callback' => 'my_plugin_get_posts',
    'permission_callback' => function() {
        return current_user_can('read_posts');
    },
]);

register_rest_route('my-plugin/v1', '/posts', [
    'methods' => 'POST',
    'callback' => 'my_plugin_create_post',
    'permission_callback' => function() {
        return current_user_can('create_posts');
    },
]);

For custom post types, WordPress generates equivalent capabilities:

<?php
// Custom post type 'book' generates these capabilities:
// read_post, read_posts, read_private_posts
// edit_post, edit_posts, edit_others_posts, edit_private_posts
// create_posts, publish_posts, delete_post, delete_posts

register_rest_route('my-plugin/v1', '/books', [
    'methods' => 'GET',
    'permission_callback' => function() {
        return current_user_can('read_books');
    },
]);

register_rest_route('my-plugin/v1', '/books', [
    'methods' => 'POST',
    'permission_callback' => function() {
        return current_user_can('edit_books') && current_user_can('publish_books');
    },
]);

Custom Permission Callbacks

For complex REST API permissions, implement sophisticated callbacks:

<?php
// Custom permission callback with multiple checks
register_rest_route('my-plugin/v1', '/user-data', [
    'methods' => 'GET',
    'callback' => 'my_plugin_get_user_data',
    'permission_callback' => function($request) {
        // Check authentication
        if (!is_user_logged_in()) {
            return false;
        }
        
        $current_user = wp_get_current_user();
        
        // Check if user can access their own data
        $user_id = $request['user_id'] ?? null;
        if ($user_id === $current_user->ID) {
            return true; // Users can access their own data
        }
        
        // Check if user is admin
        if ($current_user->has_cap('manage_options')) {
            return true;
        }
        
        // Otherwise deny
        return false;
    },
]);

Advanced pattern: Error reporting with REST error objects:

<?php
// Detailed permission errors help debugging
register_rest_route('my-plugin/v1', '/protected-data', [
    'methods' => 'GET',
    'callback' => 'my_plugin_get_protected_data',
    'permission_callback' => function($request) {
        if (!is_user_logged_in()) {
            return new WP_Error(
                'rest_not_authenticated',
                'You must be logged in to access this resource.',
                ['status' => 401]
            );
        }
        
        if (!current_user_can('read_protected_data')) {
            return new WP_Error(
                'rest_forbidden',
                'You do not have permission to access this resource.',
                ['status' => 403]
            );
        }
        
        return true;
    },
]);

Contextual Permissions for Resources

REST API permissions often depend on the specific resource being accessed:

<?php
// Contextual permissions: Allow editing own posts, deny editing others'
register_rest_route('my-plugin/v1', '/posts/(?P<id>\d+)', [
    'methods' => 'POST',
    'callback' => 'my_plugin_update_post',
    'permission_callback' => function($request) {
        $post_id = $request['id'];
        $post = get_post($post_id);
        
        if (!$post) {
            return new WP_Error('post_not_found', 'Post not found', ['status' => 404]);
        }
        
        // Check if user authored the post
        if (get_current_user_id() === intval($post->post_author)) {
            return true; // Can edit own post
        }
        
        // Check if user has edit_others_posts
        if (current_user_can('edit_others_posts')) {
            return true;
        }
        
        return false;
    },
]);

Status-based permissions:

<?php
// Published posts are readable by subscribers
// Draft posts are readable only by author/editor/admin
register_rest_route('my-plugin/v1', '/posts/(?P<id>\d+)', [
    'methods' => 'GET',
    'permission_callback' => function($request) {
        $post = get_post($request['id']);
        if (!$post) return false;
        
        if ($post->post_status === 'publish') {
            // Published posts readable by anyone who can read_posts
            return current_user_can('read_posts');
        }
        
        if ($post->post_status === 'draft') {
            // Draft posts readable only by author or editor
            $current_user_id = get_current_user_id();
            
            if ($post->post_author == $current_user_id) {
                return true; // Own draft
            }
            
            return current_user_can('edit_others_posts'); // Editor privilege
        }
        
        return false;
    },
]);

Custom Capabilities Architecture

For plugin-specific functionality, create custom capabilities:

<?php
// Register custom capabilities
add_action('admin_init', function() {
    // Add custom capabilities to admin role
    $admin = get_role('administrator');
    $admin->add_cap('manage_plugin_settings');
    $admin->add_cap('view_plugin_analytics');
    $admin->add_cap('export_plugin_data');
    
    // Add some to editor role
    $editor = get_role('editor');
    $editor->add_cap('view_plugin_analytics');
});

// Use custom capabilities in REST
register_rest_route('my-plugin/v1', '/settings', [
    'methods' => 'GET',
    'callback' => 'my_plugin_get_settings',
    'permission_callback' => function() {
        return current_user_can('manage_plugin_settings');
    },
]);

register_rest_route('my-plugin/v1', '/settings', [
    'methods' => 'POST',
    'callback' => 'my_plugin_update_settings',
    'permission_callback' => function() {
        return current_user_can('manage_plugin_settings');
    },
]);

register_rest_route('my-plugin/v1', '/analytics', [
    'methods' => 'GET',
    'callback' => 'my_plugin_get_analytics',
    'permission_callback' => function() {
        return current_user_can('view_plugin_analytics');
    },
]);

Implement capability-based access control for multi-tenant scenarios:

<?php
// Custom capability for team-based access
function user_can_manage_team($user_id, $team_id) {
    // Check if user is team owner
    $team = get_post($team_id);
    if ($team->post_author == $user_id) {
        return true;
    }
    
    // Check if user is team member with manage capability
    $team_members = get_post_meta($team_id, 'team_members', true);
    if (in_array($user_id, $team_members ?? [])) {
        $member_role = get_post_meta($team_id, "member_role_$user_id", true);
        return $member_role === 'manager';
    }
    
    return false;
}

register_rest_route('my-plugin/v1', '/teams/(?P<team_id>\d+)', [
    'methods' => 'POST',
    'permission_callback' => function($request) {
        $team_id = $request['team_id'];
        $user_id = get_current_user_id();
        
        return user_can_manage_team($user_id, $team_id);
    },
]);

REST API Permissions for Custom Post Types

Custom post types require careful permission configuration:

<?php
// Register custom post type with capabilities
register_post_type('book', [
    'labels' => [
        'name' => 'Books',
        'singular_name' => 'Book',
    ],
    'public' => true,
    'show_in_rest' => true,
    'capabilities' => [
        'create_posts' => 'create_books',
        'edit_posts' => 'edit_books',
        'edit_others_posts' => 'edit_others_books',
        'publish_posts' => 'publish_books',
        'read_private_posts' => 'read_private_books',
        'edit_private_posts' => 'edit_private_books',
        'delete_posts' => 'delete_books',
        'delete_private_posts' => 'delete_private_books',
        'delete_others_posts' => 'delete_others_books',
        'delete_published_posts' => 'delete_published_books',
        'read' => 'read_books',
    ],
    'map_meta_cap' => true, // Map primitive capabilities
]);

// REST API uses these custom capabilities automatically
// But can override with custom permission_callback
register_rest_route('my-plugin/v1', '/books', [
    'methods' => 'GET',
    'callback' => 'my_plugin_get_books',
    'permission_callback' => function() {
        // Default: Uses read_books
        return current_user_can('read_books');
    },
]);

Advanced Permission Patterns

Complex REST API permission scenarios require sophisticated approaches. Consider applications with multi-level hierarchies, shared resources, time-based access, or resource-specific permissions. These scenarios go beyond simple capability checks and require custom logic that examines the specific resource being accessed.

Real-world applications are rarely as simple as "user has capability X, therefore can access Y." Instead, permissions often depend on context. A user might be able to edit their own posts but not others'. A team member might have different permissions in different teams. A time-limited access token should grant temporary access without permanently granting capabilities.

The permission_callback in REST API exists precisely to handle this complexity. It receives the full request object, can examine the resource being accessed, and can implement arbitrarily complex logic to determine access. This is more flexible than simple capability checks.

A document-sharing application, for example, might allow users to share documents with specific other users or groups. The permission callback can't just check "can_edit_documents"—it must check whether the current user has access to the specific document being requested:

<?php
// Shared document access
register_rest_route('my-plugin/v1', '/documents/(?P<id>\d+)', [
    'methods' => 'GET',
    'permission_callback' => function($request) {
        $document_id = $request['id'];
        $current_user_id = get_current_user_id();

        // Get document
        $document = get_post($document_id);
        if (!$document) {
            return false;
        }

        // Owner always has access
        if ($document->post_author == $current_user_id) {
            return true;
        }

        // Check if shared with current user
        $shared_users = get_post_meta($document_id, 'shared_with_users', true);
        if (in_array($current_user_id, (array) $shared_users)) {
            return true;
        }

        // Check if shared with user's groups
        $user_groups = get_user_meta($current_user_id, 'groups', true);
        $shared_groups = get_post_meta($document_id, 'shared_with_groups', true);
        if ($shared_groups && array_intersect($user_groups, $shared_groups)) {
            return true;
        }

        return false;
    },
]);

Time-based permissions might restrict access during certain periods:

<?php
// Time-based access restriction
register_rest_route('my-plugin/v1', '/scheduled-content/(?P<id>\d+)', [
    'methods' => 'GET',
    'permission_callback' => function($request) {
        $content_id = $request['id'];
        $content = get_post($content_id);

        if (!$content) {
            return false;
        }

        $publish_time = strtotime($content->post_date);
        $expiry_time = get_post_meta($content_id, 'expiry_time', true);

        $now = current_time('timestamp');

        // Check if published and not expired
        if ($publish_time <= $now && (!$expiry_time || $expiry_time > $now)) {
            return true;
        }

        // Admin can always view
        return current_user_can('manage_options');
    },
]);

Taxonomy Permissions

Taxonomy operations require careful permission control:

<?php
// Register taxonomy with capabilities
register_taxonomy('book_genre', 'book', [
    'labels' => ['name' => 'Genres'],
    'hierarchical' => true,
    'show_in_rest' => true,
    'capabilities' => [
        'manage_terms' => 'manage_book_genres',
        'edit_terms' => 'edit_book_genres',
        'delete_terms' => 'delete_book_genres',
        'assign_terms' => 'assign_book_genres',
    ],
]);

// REST API permissions for taxonomies
register_rest_route('my-plugin/v1', '/genres', [
    'methods' => 'GET',
    'permission_callback' => function() {
        return current_user_can('assign_book_genres');
    },
]);

register_rest_route('my-plugin/v1', '/genres', [
    'methods' => 'POST',
    'permission_callback' => function() {
        return current_user_can('manage_book_genres');
    },
]);

register_rest_route('my-plugin/v1', '/genres/(?P<id>\d+)', [
    'methods' => 'POST',
    'permission_callback' => function() {
        return current_user_can('edit_book_genres');
    },
]);

User Data Access Control

User data requires special permission handling:

<?php
// Restrict user data access
register_rest_route('my-plugin/v1', '/users/(?P<id>\d+)', [
    'methods' => 'GET',
    'permission_callback' => function($request) {
        $user_id = $request['id'];
        $current_user_id = get_current_user_id();
        
        // Users can view their own profile
        if ($user_id == $current_user_id) {
            return true;
        }
        
        // Admins can view any user
        if (current_user_can('list_users')) {
            return true;
        }
        
        // Others cannot view
        return false;
    },
]);

// Email and sensitive fields require additional checks
register_rest_route('my-plugin/v1', '/users/(?P<id>\d+)/email', [
    'methods' => 'GET',
    'permission_callback' => function($request) {
        $user_id = $request['id'];
        $current_user_id = get_current_user_id();
        
        // Only user themselves or admins
        if ($user_id == $current_user_id || current_user_can('manage_options')) {
            return true;
        }
        
        return false;
    },
]);

Debugging Permission Issues

When permissions aren't working as expected, debugging systematically identifies the problem. Permission issues often manifest as 403 Forbidden responses in REST requests, even though the user should have access.

Implement detailed logging to understand the permission evaluation:

<?php
// Log permission decisions for debugging
register_rest_route('my-plugin/v1', '/protected-resource', [
    'methods' => 'GET',
    'permission_callback' => function($request) {
        $user_id = get_current_user_id();
        $user = wp_get_current_user();

        if (defined('WP_DEBUG') && WP_DEBUG) {
            error_log(sprintf(
                'Permission check: user_id=%d, roles=%s, caps=%s',
                $user_id,
                implode(',', $user->roles),
                implode(',', array_keys($user->caps))
            ));
        }

        // Check capability
        $has_cap = current_user_can('read_protected_resources');

        if (defined('WP_DEBUG') && WP_DEBUG) {
            error_log("Permission result: " . ($has_cap ? 'ALLOWED' : 'DENIED'));
        }

        return $has_cap;
    },
]);

Use REST client tools (Postman, Insomnia, Thunder Client) to test endpoints with different users and roles. This lets you verify permission behavior without building a full test suite.

Test these scenarios for each endpoint:

  • Unauthenticated user: Should typically get 401 Unauthorized
  • Authenticated user without capability: Should get 403 Forbidden
  • Authenticated user with capability: Should get 200 OK
  • Admin user: Should usually get 200 OK (unless endpoint explicitly denies admins)
  • Different roles: If your endpoint supports multiple roles, test each

Common permission debugging mistakes:

  1. Forgetting that current_user_can() returns false for unauthenticated users (they have no capabilities)
  2. Mixing up 401 (authentication required) vs 403 (permission denied)
  3. Not considering that custom capabilities need to be added to roles separately
  4. Assuming WordPress roles persist across permission checks when they don't

FAQ

What's the difference between permission_callback and callback?

permission_callback determines if a user can access the endpoint. callback is the function that handles the request if permission succeeds. Both must be defined separately.

Can I use role checks instead of capability checks?

You can (get_current_user_can('role:editor')), but capabilities are more flexible. Roles can be customized; capabilities are the standard WordPress authorization mechanism. Use capabilities for production code.

What happens if permission_callback returns WP_Error?

WordPress sends the error response (401 or 403 status) to the client and doesn't execute the callback. This is proper error handling for REST API.

How do I handle REST API permissions for custom plugin data?

Create custom capabilities (manage_my_plugin_data, view_my_plugin_data, etc.) and assign them to roles. Then use those in permission_callback for your REST endpoints.

Can I grant temporary REST API permissions?

Yes, using Application Passwords or JWT tokens with time-based expiration. These are more modern than nonces for REST API. Standard WordPress sessions also have expiration.

What's the best practice for read vs write permissions?

Read operations typically require fewer permissions (can_read_posts). Write operations require more (can_edit_posts). Destructive operations require even more (can_delete_posts). Structure permissions hierarchically.

Broader Context and Best Practices

Security vulnerabilities in WordPress plugins don't exist in isolation. Each vulnerability represents a potential entry point that attackers chain together to achieve broader compromise. A seemingly minor issue like improper input validation can escalate when combined with a privilege escalation flaw, turning a low-severity finding into a critical breach. This interconnected nature of security weaknesses is why comprehensive auditing matters so much. Rather than checking individual items in isolation, modern security analysis examines how different components interact and where those interactions create unexpected attack surfaces that manual review would miss entirely.

The WordPress plugin ecosystem's open-source nature creates both strengths and challenges for security. Open code allows community review, which catches many issues early. However, it also means attackers can study source code to find exploitable patterns before patches are released. This asymmetry makes proactive security testing essential rather than reactive. Developers who integrate automated security scanning into their development workflow catch vulnerabilities during development, long before code reaches production. The cost of fixing a security issue during development is orders of magnitude lower than addressing it after a public disclosure or active exploitation.

Understanding the attacker's perspective transforms how developers approach security. Attackers don't think in terms of individual functions or classes. They think in terms of data flows, trust boundaries, and privilege transitions. When data crosses from an untrusted context like user input into a trusted context like a database query, that boundary is where vulnerabilities emerge. By mapping these trust boundaries in your plugin architecture, you can systematically identify where validation, sanitization, and authorization checks are needed.

WordPress powers over forty percent of the web, making it the single largest target for automated attacks. Plugin vulnerabilities are the primary vector for these attacks, with Patchstack reporting thousands of new plugin vulnerabilities each year. The scale of the WordPress ecosystem means that even a vulnerability affecting a relatively obscure plugin can impact hundreds of thousands of sites. This reality underscores why every plugin developer has a responsibility to take security seriously.

Broader Industry Context and Best Practices

Security hardening in WordPress extends beyond individual plugin fixes to encompass a holistic defense strategy. Organizations managing multiple WordPress installations benefit from centralized security policies that enforce consistent standards across all sites. This includes automated vulnerability scanning, real-time threat intelligence feeds, and coordinated patch management. WP HealthKit provides the automated scanning infrastructure that makes centralized security monitoring practical, giving teams visibility into vulnerabilities across their entire WordPress portfolio. Regular security assessments should evaluate not just known vulnerabilities but also configuration drift, where settings gradually deviate from security baselines over time, creating subtle but exploitable weaknesses.

The WordPress security landscape continues evolving as attackers develop increasingly sophisticated techniques. Supply chain attacks targeting plugin update mechanisms, zero-day exploits in popular themes, and credential stuffing campaigns against wp-admin endpoints represent growing threat vectors. Effective defense requires layered security controls: web application firewalls filter malicious requests, file integrity monitoring detects unauthorized changes, and behavioral analysis identifies anomalous patterns. WP HealthKit scans for these vulnerability patterns automatically, helping teams stay ahead of emerging threats. Security teams should also implement network segmentation to limit lateral movement if an attacker compromises a single WordPress instance within a larger infrastructure.

Compliance requirements add another dimension to WordPress security planning. Organizations in regulated industries must demonstrate that their WordPress deployments meet specific security standards, whether PCI DSS for payment processing, HIPAA for healthcare data, or SOC 2 for service providers. This means maintaining detailed audit trails, implementing access controls with principle of least privilege, and conducting regular penetration testing. WP HealthKit audit reports provide documentation that supports compliance evidence gathering, making it easier to demonstrate security due diligence during audits. Automated compliance checking reduces the manual effort required for audit preparation while ensuring continuous adherence to security requirements throughout the year.

Incident preparedness separates resilient WordPress deployments from vulnerable ones. Before a security incident occurs, teams should establish clear incident response procedures, including communication templates, escalation paths, and forensic preservation protocols. Regular tabletop exercises help teams practice their response procedures, identifying gaps before real incidents expose them. Post-incident reviews should analyze root causes systematically, implementing both immediate fixes and longer-term architectural improvements to prevent recurrence. WP HealthKit helps organizations maintain continuous security visibility, which is essential for rapid incident detection and response. Building a security-conscious culture where all team members understand their role in maintaining WordPress security creates the strongest defense against evolving threats.

Frequently Asked Questions

How does WP HealthKit detect security vulnerabilities automatically?

WP HealthKit uses 62 verification layers including static analysis, pattern matching, and dependency scanning to identify vulnerabilities in WordPress plugins. The automated scanning catches issues that manual code review would miss, providing comprehensive security coverage across your entire codebase.

What are the most common WordPress plugin security vulnerabilities?

The most frequently discovered vulnerabilities include cross-site scripting through improper output escaping, SQL injection via unparameterized queries, cross-site request forgery from missing nonce verification, and privilege escalation through inadequate capability checks. These four categories account for over seventy percent of all reported plugin vulnerabilities.

How often should I audit my WordPress plugin for security issues?

Security audits should happen at every major release, after significant code changes, and on a regular quarterly schedule. Automated scanning through CI/CD pipelines provides continuous monitoring, while thorough manual reviews should complement automated testing at least twice per year.

Can automated tools replace manual security code review?

Automated tools like WP HealthKit catch the majority of common vulnerability patterns quickly and consistently, but they complement rather than replace manual review. Complex business logic vulnerabilities, architectural issues, and novel attack vectors still benefit from expert human analysis. The ideal approach combines both.

What should I do if a vulnerability is discovered in my plugin?

Follow responsible disclosure practices: verify the vulnerability, develop and test a fix, notify affected users through your update channel, and publish a security advisory. Coordinate with the WordPress security team if the vulnerability is severe. Speed matters — most attackers begin exploitation within days of public disclosure.

Conclusion

WordPress REST API permissions control access to your data and functionality. Proper permission_callback configuration is non-negotiable for security. Every endpoint must verify user authentication and authorization before returning data or processing requests.

Key patterns: use standard WordPress capabilities where applicable, create custom capabilities for plugin-specific features, implement contextual permissions that depend on resource attributes, and always return early with permission errors when access should be denied.

WP HealthKit audits REST API endpoints for proper permission configuration, identifying endpoints without callbacks, weak permissions, and capability check vulnerabilities.

Ready to audit your REST API security? Upload your plugins to WP HealthKit for detailed analysis of endpoint permissions, capability checks, and authentication patterns.


Ready to audit your plugin?

WP HealthKit checks for all the issues in this article and 40+ more across 63 verification layers.

Comments