Register Custom REST API Endpoints with Authentication and Schema Validation in WordPress

WordPress REST API routes registered via register_rest_route() require three arguments: the namespace (conventionally myplugin/v1), the route path with optional regex capture groups, and an array of route arguments that specify the HTTP methods, the callback, the permission callback, and the args schema. The permission_callback is mandatory since WordPress 5.5 — returning true from it allows public access, while checking current_user_can() or validating an application password restricts access to authenticated users. The args array maps parameter names to schema arrays that declare type, required, default, minimum, maximum, enum, and sanitize_callback — WordPress runs these validations automatically before the route callback is called, returning a 400 Bad Request with a descriptive error message for any parameter that fails. Omitting sanitize_callback in the args schema is a security risk: always sanitize text parameters with sanitize_text_field or rest_sanitize_array, and integers with absint. The validate_callback in each arg runs before sanitize_callback and should return true or a WP_Error object with a specific code — never a plain boolean false, which produces a generic error message. For endpoints that create or update resources, the HTTP verb should be POST or PUT and the route callback should return a WP_REST_Response with the appropriate HTTP status code (201 for created, 200 for updated, 204 for deleted with no body). Registering the same namespace for multiple routes with different methods on the same path is done by passing an array of method arrays to a single register_rest_route() call — each inner array specifies its own methods, callback, permission_callback, and args. REST API route registration always happens on the rest_api_init hook, not init, to ensure the REST server is fully initialized. The schema is discoverable via OPTIONS requests to the route URL, which helps API clients auto-generate type-safe request builders. The dynamic block post uses the REST API internally for server-side rendering — the same authentication and schema patterns apply to any custom endpoint that the block editor queries.

Problem: Custom WordPress REST endpoints built without a permission callback or args schema expose unauthenticated access and accept arbitrary input, creating authentication bypass and injection vulnerabilities.

Solution: Register routes on rest_api_init with an explicit permission_callback that checks capabilities, declare every parameter in the args schema with type, sanitize_callback, and validate_callback, and return WP_REST_Response objects with correct HTTP status codes.

// Register two endpoints under the same route: GET (list) and POST (create)
add_action('rest_api_init', function() {
    register_rest_route('myplugin/v1', '/items', [
        // GET /wp-json/myplugin/v1/items
        [
            'methods'             => WP_REST_Server::READABLE,
            'callback'            => 'myplugin_get_items',
            'permission_callback' => '__return_true', // public read
            'args'                => [
                'per_page' => [
                    'type'              => 'integer',
                    'default'           => 10,
                    'minimum'           => 1,
                    'maximum'           => 100,
                    'sanitize_callback' => 'absint',
                ],
                'search' => [
                    'type'              => 'string',
                    'default'           => '',
                    'sanitize_callback' => 'sanitize_text_field',
                ],
            ],
        ],
        // POST /wp-json/myplugin/v1/items
        [
            'methods'             => WP_REST_Server::CREATABLE,
            'callback'            => 'myplugin_create_item',
            'permission_callback' => function() {
                return current_user_can('edit_posts');
            },
            'args' => [
                'title' => [
                    'type'              => 'string',
                    'required'          => true,
                    'sanitize_callback' => 'sanitize_text_field',
                    'validate_callback' => function($value) {
                        if (strlen($value) < 3) {
                            return new WP_Error('too_short', 'Title must be at least 3 characters.');
                        }
                        return true;
                    },
                ],
                'status' => [
                    'type'              => 'string',
                    'enum'              => ['active', 'inactive', 'draft'],
                    'default'           => 'draft',
                    'sanitize_callback' => 'sanitize_key',
                ],
            ],
        ],
    ]);
});

function myplugin_get_items(WP_REST_Request $request): WP_REST_Response {
    $per_page = $request->get_param('per_page'); // already sanitized and validated
    $search   = $request->get_param('search');

    // Query your custom table or post type here
    $items = []; // placeholder

    return new WP_REST_Response($items, 200);
}

function myplugin_create_item(WP_REST_Request $request): WP_REST_Response {
    $title  = $request->get_param('title');
    $status = $request->get_param('status');

    // Insert into DB — use $wpdb->insert() with prepared statements
    global $wpdb;
    $wpdb->insert(
        $wpdb->prefix . 'myplugin_items',
        ['title' => $title, 'status' => $status, 'created_at' => current_time('mysql', true)],
        ['%s', '%s', '%s']
    );
    $id = $wpdb->insert_id;

    return new WP_REST_Response(['id' => $id, 'title' => $title, 'status' => $status], 201);
}

NOTE: Application Passwords (introduced in WordPress 5.6) are the recommended authentication method for server-to-server REST API calls — send them as a Basic Authorization header (base64(username:app_password)). Never use cookie authentication for cross-origin requests; it requires a matching nonce in the X-WP-Nonce header and only works from the same domain.