Home / Blog / Custom WP REST API endpoints: authentication and capability checks that hold up

Custom WP REST API endpoints: authentication and capability checks that hold up

You're writing a custom WordPress REST API endpoint. The security discipline: authentication, authorization, rate limiting.

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 verb
  • callback: handler function
  • permission_callback: authorization check
  • args: 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.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch