← All posts

Designing Secure Custom REST Endpoints in WordPress for Internal Tools

Designing Secure Custom REST Endpoints in WordPress for Internal Tools

When architecting a WordPress backend for anything beyond a simple blog, there often comes a point where you need to expose specific data or functionality to an external, non-WordPress application. This might be an internal dashboard, a mobile app, or another microservice. Relying solely on the default `/wp/v2` endpoints isn't always granular or secure enough for custom data structures or specific business logic. This is where designing your own custom REST endpoints becomes invaluable, particularly when focusing on robust authentication and precise permission handling.

My approach for these scenarios typically involves defining a dedicated API namespace and leveraging WordPress's built-in `register_rest_route` function. Let's imagine we're building a plugin that manages internal "Projects," defined as a custom post type. Our goal is to expose a list of these projects, but only to authorized internal applications, not publicly.

First, we'd register our custom post type, something like this:

register_post_type( 'project', array( 'labels' => array( 'name' => __( 'Projects', 'my-plugin-textdomain' ), 'singular_name' => __( 'Project', 'my-plugin-textdomain' ), ), 'public' => true, 'has_archive' => true, 'show_in_rest' => true, // Important for REST API 'capabilities' => array( 'edit_post' => 'edit_projects', 'read_post' => 'read_project', 'delete_post' => 'delete_projects', 'edit_posts' => 'edit_projects', 'edit_others_posts' => 'edit_others_projects', 'publish_posts' => 'publish_projects', 'read_private_posts' => 'read_private_projects', 'create_posts' => 'edit_projects', ), 'map_meta_cap' => true, ) );

Note the `show_in_rest => true` which makes the default `/wp/v2/projects` endpoint available. While useful, it might expose more data than we need or in a format not optimized for our external app.

Next, we define our custom endpoint within a unique namespace (e.g., `my-plugin/v1`). This prevents conflicts and clearly scopes our API.

add_action( 'rest_api_init', function () { register_rest_route( 'my-plugin/v1', '/projects', array( 'methods' => 'GET', 'callback' => 'my_plugin_get_projects_callback', 'permission_callback' => 'my_plugin_projects_permission_check', 'args' => array( 'status' => array( 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => function( $param, $request, $key ) { return in_array( $param, [ 'publish', 'draft', 'pending' ] ); }, 'required' => false, 'description' => 'Filter projects by status (publish, draft, pending).', 'type' => 'string', ), ), ) ); } );

The `callback` function, `my_plugin_get_projects_callback`, is where we fetch and format the project data:

function my_plugin_get_projects_callback( WP_REST_Request $request ) { $args = array( 'post_type' => 'project', 'posts_per_page' => -1, 'post_status' => $request->get_param( 'status' ) ?: 'publish', // Default to 'publish' );

$projects = get_posts( $args ); $data = []; foreach ( $projects as $project ) { $data[] = [ 'id' => $project->ID, 'title' => $project->post_title, 'slug' => $project->post_name, 'status'=> $project->post_status, // Add other custom fields/meta as needed ]; }

return new WP_REST_Response( $data, 200 ); }

Now, for authentication. For external applications, relying on cookie-based authentication isn't practical. This is where Application Passwords shine. Introduced in WordPress 5.6, they allow specific applications to authenticate as a given user without needing their primary password. We define our `permission_callback`, `my_plugin_projects_permission_check`, to leverage this.

function my_plugin_projects_permission_check( WP_REST_Request $request ) { // Check if the current user (authenticated via Application Password) has the required capability if ( current_user_can( 'manage_projects' ) ) { // Or any custom capability specific to your needs return true; } return new WP_REST_Response( 'You are not authorized to access this resource.', 401 ); }

To use this, an administrator would go to their WordPress profile, generate an Application Password, and provide it to the external application. The external application would then include it in the `Authorization` header as a Bearer token. For example, a `curl` request might look like this:

curl -X GET \ http://yourdomain.com/wp-json/my-plugin/v1/projects \ -H 'Authorization: Basic <base64-encoded username:application-password>'

Remember, the "Basic" authentication header expects a base64-encoded string of `username:application-password`. You'd typically create a specific user role and assign only the necessary custom capabilities (like `manage_projects` we defined for our CPT) to ensure granular access control. This combination of custom endpoints, granular permissions via `permission_callback`, and secure Application Passwords provides a robust, backend-focused solution for exposing WordPress data to the wider application ecosystem. It keeps our data secure, our API clean, and our WordPress core untouched by external application logic.