The WordPress REST API has been part of core since 2016. Frontends (React, mobile apps) consume the WP backend as an API. Writing a custom endpoint is practical, but the security discipline is non-negotiable.
I’ve been building WP plugins for years and have seen the same REST endpoint mistakes over and over. This post is the proper implementation.
Basic endpoint
add_action('rest_api_init', function() {
register_rest_route('myapi/v1', '/orders/(?P<id>d+)', [
'methods' => 'GET',
'callback' => 'get_order',
'permission_callback' => 'can_read_order',
'args' => [
'id' => [
'required' => true,
'validate_callback' => fn($v) => is_numeric($v) && $v > 0,
'sanitize_callback' => 'absint'
]
]
]);
});
function get_order(WP_REST_Request $request): WP_REST_Response {
$id = $request['id'];
$order = get_order_data($id);
return new WP_REST_Response($order, 200);
}
function can_read_order(WP_REST_Request $request): bool {
return current_user_can('read_orders');
}That’s the minimum good practice. Every endpoint has four elements:
methods: HTTP verbcallback: handler functionpermission_callback: authorization checkargs: parameter validation
Authentication methods
The WP REST API supports multiple auth methods:
1. Cookie authentication (default). A logged-in WP user, browser cookie. The frontend has to be same-origin.
2. Application passwords. Built in since WP 5.6. You generate an app password for a user account. Basic auth header.
Authorization: Basic base64(username:app_password)Used for mobile apps and third-party integrations.
3. JWT. Via plugin. Stateless, bearer token. Popular for modern SPAs.
4. OAuth 2.0. Via plugin. Third-party app authorization.
My preference: application passwords most of the time. JWT for SPA-specific cases.
Permission callback patterns
Pattern 1: Public endpoint.
'permission_callback' => '__return_true'Careful: this is usually wrong. Unauthenticated users can pull everything. Only use it for genuinely public data (blog posts, listings).
Pattern 2: Logged-in user.
'permission_callback' => function() {
return is_user_logged_in();
}Minimum auth. The user is logged in.
Pattern 3: Capability check.
'permission_callback' => function() {
return current_user_can('manage_options');
}The WP capability system. manage_options is admin, edit_posts is author or higher.
Pattern 4: Custom logic.
'permission_callback' => function(WP_REST_Request $request) {
$order_id = $request['id'];
$user_id = get_current_user_id();
return user_owns_order($user_id, $order_id) || current_user_can('manage_orders');
}Specific business rule.
Input validation
Validate and sanitize every parameter:
'args' => [
'email' => [
'required' => true,
'validate_callback' => function($value) {
return is_email($value);
},
'sanitize_callback' => 'sanitize_email'
],
'age' => [
'required' => false,
'validate_callback' => function($value) {
return is_numeric($value) && $value >= 0 && $value <= 150;
},
'sanitize_callback' => 'absint',
'default' => null
],
'tags' => [
'required' => false,
'validate_callback' => function($value) {
return is_array($value) && count($value) <= 10;
},
'sanitize_callback' => function($value) {
return array_map('sanitize_text_field', $value);
}
]
]validate_callback: returns true or WP_Error. Invalid means 400.sanitize_callback: makes the value safe. The output is what gets passed to the callback.
SQL injection prevention
Always $wpdb->prepare():
// GOOD
$results = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}orders WHERE user_id = %d AND status = %s",
$user_id,
$status
));
// BAD - SQL injection risk
$results = $wpdb->get_results(
"SELECT * FROM {$wpdb->prefix}orders WHERE user_id = $user_id"
);Every user-provided value gets parameterized as a placeholder.
Output escaping
Escape response data on the way out:
function get_order(WP_REST_Request $request): WP_REST_Response {
$id = $request['id'];
$order = get_order_raw_data($id);
return new WP_REST_Response([
'id' => (int) $order['id'],
'title' => esc_html($order['title']),
'description' => wp_kses_post($order['description']), // HTML allowed but sanitized
'url' => esc_url($order['url']),
'email' => sanitize_email($order['email'])
], 200);
}Rate limiting
The WP REST API has no default rate limiting. Roll your own:
function rate_limit_check(WP_REST_Request $request): bool {
$user_id = get_current_user_id() ?: $_SERVER['REMOTE_ADDR'];
$key = "rate_limit_{$user_id}";
$count = (int) get_transient($key);
if ($count >= 100) { // 100 requests per minute
return false;
}
set_transient($key, $count + 1, 60);
return true;
}
// Check in the permission callback:
'permission_callback' => function($request) {
if (!rate_limit_check($request)) {
return new WP_Error('rate_limit', 'Too many requests', ['status' => 429]);
}
return current_user_can('read');
}Transients are built into WordPress. For production, Redis is recommended.
Error responses
Consistent error format:
function get_order(WP_REST_Request $request): WP_REST_Response {
$id = $request['id'];
$order = get_order_data($id);
if (!$order) {
return new WP_REST_Response([
'code' => 'order_not_found',
'message' => 'Order not found',
'data' => ['status' => 404]
], 404);
}
return new WP_REST_Response($order, 200);
}Use correct HTTP status codes:
- 200: success
- 201: created
- 400: bad request (validation)
- 401: unauthenticated
- 403: unauthorized (authenticated but no permission)
- 404: not found
- 429: rate limited
- 500: server error
Namespaces
register_rest_route('myplugin/v1', '/endpoint', [...]);myplugin is your plugin namespace. v1 is the version. WP convention is slash-separated.
For a new version:
register_rest_route('myplugin/v2', '/endpoint', [...]);v1 stays around for backward compat.
Pagination
Pagination on list endpoints:
register_rest_route('myapi/v1', '/orders', [
'methods' => 'GET',
'callback' => 'list_orders',
'args' => [
'per_page' => [
'default' => 20,
'validate_callback' => fn($v) => is_numeric($v) && $v > 0 && $v <= 100
],
'page' => [
'default' => 1,
'validate_callback' => fn($v) => is_numeric($v) && $v >= 1
]
]
]);
function list_orders(WP_REST_Request $request) {
$per_page = $request['per_page'];
$page = $request['page'];
$offset = ($page - 1) * $per_page;
$total = get_total_orders();
$orders = get_orders(['limit' => $per_page, 'offset' => $offset]);
$response = new WP_REST_Response($orders, 200);
$response->header('X-Total-Count', $total);
$response->header('X-Total-Pages', ceil($total / $per_page));
return $response;
}Total count in the header. Frontend can build pagination off it.
CORS
Frontend calling from a different origin:
add_action('rest_api_init', function() {
remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
add_filter('rest_pre_serve_request', function($value) {
$origin = get_http_origin();
$allowed = ['https://app.example.com', 'https://staging.example.com'];
if (in_array($origin, $allowed)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE');
header('Access-Control-Allow-Headers: Authorization, Content-Type');
}
return $value;
});
}, 15);Never use a wildcard in production. Specific allowed origins.
Documentation
Document your REST endpoints:
OpenAPI/Swagger compatible:
register_rest_route('myapi/v1', '/orders/(?P<id>d+)', [
'methods' => 'GET',
'callback' => 'get_order',
'permission_callback' => 'can_read_order',
'args' => [...],
'schema' => [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'order',
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'title' => ['type' => 'string'],
'status' => ['type' => 'string', 'enum' => ['pending', 'completed', 'cancelled']]
]
]
]);Client developers can generate a typed API client off the schema.
Testing
Endpoint tests in PHPUnit:
class OrderEndpointTest extends WP_UnitTestCase {
public function test_get_order_success() {
$user_id = $this->factory->user->create(['role' => 'administrator']);
wp_set_current_user($user_id);
$request = new WP_REST_Request('GET', '/myapi/v1/orders/1');
$response = rest_do_request($request);
$this->assertEquals(200, $response->get_status());
$this->assertArrayHasKey('id', $response->get_data());
}
public function test_get_order_unauthorized() {
$request = new WP_REST_Request('GET', '/myapi/v1/orders/1');
$response = rest_do_request($request);
$this->assertEquals(401, $response->get_status());
}
}Takeaway
Writing a WP REST API custom endpoint: authentication, permission check, input validation, output escaping, rate limiting, consistent error responses. Those six are the baseline.
Respect WordPress conventions: namespaces, the capability system, existing security patterns.
Security is proactive, not reactive. Every endpoint has explicit checks. A plugin ready for production has test coverage plus a security audit.