Contents
Overview
This article explains, in full detail, how to restrict WordPress REST API endpoints by capability using PHP. It covers the theory (roles vs capabilities), multiple practical methods (permission_callback, rest_endpoints, rest_pre_dispatch, custom controllers, rest_authentication_errors), examples you can copy into plugins, how to check post-specific (meta) capabilities, proper error handling, and common pitfalls and best practices.
Why restrict REST endpoints by capability?
WordPress REST endpoints are powerful but — like any API — must be protected. By restricting endpoints using capabilities you ensure only authorized users can perform actions (read, create, update, delete) or access sensitive data. Capabilities are the granular security primitives in WP (e.g., publish_posts, edit_post, manage_options) and are more flexible than hard-coded roles.
Roles vs capabilities (brief)
- Role: a named collection of capabilities (e.g., editor, administrator).
- Capability: a single permission, often mapped to responsibilities (e.g., edit_posts, edit_others_posts, manage_options).
When to enforce capability checks
- When registering a new custom REST route.
- When modifying or restricting existing core or plugin-provided endpoints.
- When an endpoint accepts a resource identifier (post ID, user ID) and you must enforce meta-cap checks (edit_post, delete_post).
- When you need per-request logic (dynamic capabilities based on request data).
Principles
- Always use capability checks in the REST layer (permission callbacks), not only at the controller or template level.
- Return WP_Error instances on denial, with proper HTTP status (403).
- Prefer returning true from a permission_callback when allowed, WP_Error when denied.
- Use meta-cap checks (current_user_can(edit_post, post_id)) when a capability depends on a specific resource.
Methods to restrict endpoints
- Use permission_callback when you register a custom route (recommended for custom routes).
- Modify existing endpoints with the rest_endpoints filter to swap or wrap permission callbacks.
- Intercept requests early with rest_pre_dispatch to block them before controller handling.
- Extend WP_REST_Controller and implement a permissions checker method.
- Use rest_authentication_errors to short-circuit authentication/authorization when needed.
1) permission_callback when registering custom routes (recommended)
When you register a custom endpoint with register_rest_route you can provide a permission_callback. That callback should return true if the current request is allowed, or a WP_Error if it should be denied (or false — but WP_Error with status is preferred).
Example: Allow only administrators (manage_options) to access a GET endpoint.
add_action(rest_api_init, function() { register_rest_route(my-plugin/v1, /secret, array( methods => GET, callback => my_plugin_secret_callback, permission_callback => function() { if ( ! current_user_can(manage_options) ) { return new WP_Error( rest_forbidden, You do not have permission to view this resource., array( status => 403 ) ) } return true }, )) }) function my_plugin_secret_callback( request ) { return rest_ensure_response( array( secret => top-secret-value ) ) }
Post-specific (meta-cap) example
Meta capabilities (like edit_post or delete_post) require a post ID. Use the request data to check the capability for that resource.
add_action(rest_api_init, function() { register_rest_route(my-plugin/v1, /post/(?Pd ), array( array( methods => POST, callback => my_plugin_update_post, permission_callback => function( request ) { post_id = (int) request[id] if ( ! current_user_can( edit_post, post_id ) ) { return new WP_Error( rest_forbidden, You are not allowed to edit this post., array( status => 403 ) ) } return true }, ), )) }) function my_plugin_update_post( request ) { // Update logic here WP capability was already checked. return rest_ensure_response( array( success => true ) ) }
2) Modify existing endpoints with rest_endpoints
If you need to adjust core or third-party endpoints, use the rest_endpoints filter. This allows you to inspect all registered routes and replace or wrap their permission_callback functions. This is powerful but must be used carefully because endpoints can be registered after your filter depending on plugin load order so use a late priority if needed.
Example: Require publish_posts capability to create posts via the core posts route (POST /wp/v2/posts).
add_filter( rest_endpoints, function( endpoints ) { route = /wp/v2/posts if ( empty( endpoints[ route ] ) ) { return endpoints } foreach ( endpoints[ route ] as i => endpoint ) { // methods can be a string like GET or an array. methods = isset( endpoint[methods] ) ? (array) endpoint[methods] : array() // If the endpoint supports creating posts (POST), replace permission callback. if ( in_array( POST, methods, true ) in_array( POST, array_map(strtoupper, methods), true ) ) { endpoints[ route ][ i ][permission_callback ] = function( request ) { if ( ! current_user_can( publish_posts ) ) { return new WP_Error( rest_forbidden, You cannot create posts via the REST API., array( status => 403 ) ) } return true } } } return endpoints }, 10 )
Notes about rest_endpoints
- Be careful with endpoints registered later — ensure your filter runs after they’re added (use priority or run code on rest_api_init).
- Always return the endpoints array even if you don’t modify it.
3) Intercept requests with rest_pre_dispatch
If you want to check capabilities early and possibly block before controller execution, use the rest_pre_dispatch filter. This is called after routing but before the controller executes. Returning a WP_Error or WP_REST_Response here stops normal handling.
add_filter( rest_pre_dispatch, function( result, server, request ) { // Intercept POST creation of posts: route can be /wp/v2/posts route = request->get_route() method = strtoupper( request->get_method() ) if ( route === /wp/v2/posts method === POST ) { if ( ! current_user_can( publish_posts ) ) { return new WP_Error( rest_forbidden, You cannot create posts via REST., array( status => 403 ) ) } } // Return the original result (usually null) to continue normal flow. return result }, 10, 3 )
4) Extend WP_REST_Controller and implement permissions check
If you are building a controller-style endpoint, subclass WP_REST_Controller and define permission checker methods. This keeps code organized and is the pattern used in core controllers.
class My_Private_Controller extends WP_REST_Controller { public function register_routes() { namespace = my-plugin/v1 base = private register_rest_route( namespace, / . base, array( array( methods => WP_REST_Server::READABLE, callback => array( this, get_items ), permission_callback => array( this, get_items_permissions_check ), ), ) ) } public function get_items_permissions_check( request ) { if ( ! current_user_can( read_private_pages ) ) { return new WP_Error( rest_forbidden, You are not allowed to read this resource., array( status => 403 ) ) } return true } public function get_items( request ) { return rest_ensure_response( array( data => secure data ) ) } } add_action( rest_api_init, function() { controller = new My_Private_Controller() controller->register_routes() } )
5) Use rest_authentication_errors to short-circuit
The rest_authentication_errors filter allows you to return a WP_Error early in the authentication phase. It’s primarily for authentication errors, but you can inspect the current user and return a WP_Error when they lack capabilities. Use this sparingly — it affects all REST requests and can be blunt.
add_filter( rest_authentication_errors, function( result ) { // If other authentication filters already returned an error, keep it. if ( is_wp_error( result ) ) { return result } // Example: block all REST calls for non-admins to a site-specific reason: if ( ! current_user_can( manage_options ) ) { return new WP_Error( rest_forbidden, REST access is limited to administrators on this site., array( status => 403 ) ) } return result } )
Returning correct errors
When denying access from a permission_callback or filter, return a WP_Error with an appropriate code and a 403 HTTP status. Example fields:
- code: rest_forbidden (common)
- message: Human-readable denial reason (avoid leaking sensitive details)
- data: array( status =gt 403 )
Common capability checks and examples
- current_user_can(manage_options) — admin-only actions (settings, critical areas).
- current_user_can(publish_posts) — create/publish posts.
- current_user_can(edit_posts) — can edit own posts.
- current_user_can(edit_post, post_id) — edit a specific post (meta capability).
- current_user_can(delete_post, post_id) — delete a specific post.
- current_user_can(edit_others_posts) — edit other users posts.
Full plugin example: Restrict selected core endpoints
Below is a compact plugin-style example that limits creation/update/delete of posts and pages via the REST API to users with the appropriate capabilities.
/ Plugin Name: REST Capability Gate Description: Restrict core REST endpoints by capability. Version: 1.0 Author: Example / add_filter( rest_endpoints, function( endpoints ) { // Routes we care about: targets = array( /wp/v2/posts, /wp/v2/pages, ) foreach ( targets as route ) { if ( empty( endpoints[ route ] ) ) { continue } foreach ( endpoints[ route ] as index => endpoint ) { methods = isset( endpoint[methods] ) ? (array) endpoint[methods] : array() // Protect write methods: write_methods = array( POST, PUT, PATCH, DELETE ) upper_methods = array_map( strtoupper, methods ) if ( array_intersect( upper_methods, write_methods ) ) { // Replace permission_callback for write operations: endpoints[ route ][ index ][permission_callback ] = function( request ) use ( route ) { method = strtoupper( request->get_method() ) if ( in_array( method, array( POST ), true ) ) { // Create — need publish capability if ( ! current_user_can( publish_posts ) ) { return new WP_Error(rest_forbidden, You cannot create content via REST., array(status => 403)) } } elseif ( in_array( method, array( PUT, PATCH ), true ) ) { // Update — check edit_post meta capability if ID is present. id = request->get_param(id) ?: request->get_param(post) ?: null if ( id ) { if ( ! current_user_can( edit_post, (int) id ) ) { return new WP_Error(rest_forbidden, You cannot edit this item., array(status => 403)) } } else { // Fallback: require edit_posts if ( ! current_user_can( edit_posts ) ) { return new WP_Error(rest_forbidden, You cannot edit items via REST., array(status => 403)) } } } elseif ( method === DELETE ) { id = request->get_param(id) ?: null if ( id ) { if ( ! current_user_can( delete_post, (int) id ) ) { return new WP_Error(rest_forbidden, You cannot delete this item., array(status => 403)) } } else { if ( ! current_user_can( delete_posts ) ) { return new WP_Error(rest_forbidden, You cannot delete items via REST., array(status => 403)) } } } return true } } } } return endpoints } )
Best practices and security considerations
- Least privilege: Give endpoints the minimum capability required for the action.
- Use meta-cap checks: For post/user-specific operations always check meta capabilities (edit_post, delete_user) with the resource ID.
- Return safe error messages: Avoid exposing internal logic or indicating whether a resource exists when access is denied.
- Consider authentication methods: Nonce-based cookie auth and Application Passwords both authenticate as a user — capability checks still apply to the authenticated user.
- Order of filters: If you modify core endpoints, ensure your code runs after those endpoints are registered (rest_endpoints filter runs at rest_api_init time by default).
- Avoid global blocks unless intentional: rest_authentication_errors affects all rest requests site-wide — use with care.
- Logging: Log important permission rejections if you need audit trails, but do so without leaking sensitive info to public logs.
- Rate limiting and brute force: Capability checks stop unauthorized actions, but consider rate limiting for abusive patterns.
Troubleshooting common problems
-
Permission_callback not called / still allowed:
Ensure your permission_callback returns WP_Error or true. Returning false may not always produce a proper 403 response prefer WP_Error with status =gt 403.
-
rest_endpoints changes not applied:
Check timing — the filter must run after endpoints are registered. Hook at rest_api_init or adjust priority.
-
Application Passwords or Basic Auth bypass:
Application Passwords authenticate as the user they belong to — capability checks still evaluate that users capabilities. If you see unexpected allowances, verify which user the request authenticates as.
-
Missing post ID for meta-cap checks:
Make sure your permission callback inspects request body or route parameters for the resource ID. For PATCH/PUT requests the ID sometimes comes from the route rather than the body.
Summary
Restricting REST endpoints by capability in PHP is essential for secure WordPress sites. For custom routes, use permission_callback. For core or third-party routes, consider the rest_endpoints filter or rest_pre_dispatch for request-level control. Always use meta capabilities for resource-specific checks, return WP_Error with 403 status on denial, and follow least-privilege and logging best practices.
Further reading: WordPress REST API handbook — https://developer.wordpress.org/rest-api/
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |