☰
WORDPRESS / PRACTICE

How to Secure Custom REST API Endpoints in WordPress

How to Secure Custom REST API Endpoints in WordPress

A custom REST API endpoint in WordPress is convenient for AJAX requests, mobile applications, user dashboards, or integrations with other systems. However, every such route becomes a separate access point to your data. If permissions are configured incorrectly or excessive information is returned, an endpoint can become a source of data leakage.

The most dangerous scenario is leaving permission_callback set to __return_true while the callback returns orders, posts, user email addresses, internal metadata, or other sensitive data. Below is a practical approach to registering custom REST routes, checking access, processing parameters, and testing them safely.

Determine Who Really Needs Access

Before writing any code, determine whether the endpoint should be public, available to authenticated users, or restricted to a specific role. A public route is appropriate when every field in its response is intended for anonymous viewing—for example, a list of categories, titles of published content, or a publicly available service status.

If an endpoint returns personal, commercial, or administrative data, access must be restricted. A hidden URL is not a security measure: the route can be discovered in JavaScript code, mobile application documentation, logs, or browser network requests.

It is important to distinguish authentication from authorization. Authentication answers the question of who the user is. Authorization determines whether that specific user has permission to perform a particular operation or view a particular object.

  • Public data: permission_callback may allow access without login.
  • Authenticated user data: verify that the user is logged in and has access to their own objects.
  • Administrative or financial data: check the appropriate capability.
  • Operations involving a specific post, order, or user: additionally verify permission for that particular object.

Configure permission_callback Instead of Relying on the Callback

In register_rest_route(), permission_callback should be a separate barrier that runs before the main logic. Do not retrieve data first and then check permissions inside the callback: this makes the code more complicated and increases the risk of mistakes.

For a private endpoint, check the capability that corresponds to the operation. For example, manage_woocommerce is appropriate for accessing administrative WooCommerce data, but it should not automatically be used for ordinary customers viewing their own orders.

  • Content management may use edit_posts, publish_posts, or edit_post.
  • Site settings generally require the manage_options capability if the operation is genuinely administrative.
  • For a specific object, use checks such as current_user_can('edit_post', $post_id).
  • Do not replace permission checks with a condition such as $_GET['token'] === 'secret': such tokens often end up in logs, browser history, or referrers.
register_rest_route('site/v1', '/orders', [
    'methods'  => WP_REST_Server::READABLE,
    'callback' => 'site_get_orders',
    'permission_callback' => function () {
        return current_user_can('manage_woocommerce');
    },
]);

Check Authentication and Object-Level Permissions

A role check is not enough for an endpoint that works with a specific post or order. A user may be allowed to view their own data but not another user's data. Therefore, after obtaining the object identifier, verify that it belongs to the current user or check the capability for editing or viewing that specific object.

For WordPress REST API requests using cookie authentication, the request must include a valid nonce, usually passed in the X-WP-Nonce header. A nonce helps protect against CSRF, but it does not replace capability checks. For integrations without a browser session, use an appropriate authentication method, such as Application Passwords or the mechanism provided by the relevant system.

Do not treat CORS or User-Agent restrictions as access control. They may affect client behavior, but they do not replace server-side checks.

  • Get the current user through the WordPress API rather than from a request parameter.
  • Compare the resource owner with get_current_user_id().
  • For editing or deleting, check the capability for the specific post or other object.
  • Return status 401 for an unauthenticated request and 403 when the user is logged in but lacks the required permissions.
function site_get_order(WP_REST_Request $request) {
    $order_id = absint($request['id']);
    $order = wc_get_order($order_id);

    if (!$order) {
        return new WP_Error(
            'order_not_found',
            'Замовлення не знайдено.',
            ['status' => 404]
        );
    }

    $user_id = get_current_user_id();
    $can_manage = current_user_can('manage_woocommerce');
    $is_owner = (int) $order->get_user_id() === $user_id;

    if (!$can_manage && !$is_owner) {
        return new WP_Error(
            'forbidden',
            'Недостатньо прав для перегляду цього замовлення.',
            ['status' => 403]
        );
    }

    return [
        'id'     => $order->get_id(),
        'status' => $order->get_status(),
        'total'  => $order->get_total(),
    ];
}

Sanitize and Validate Request Parameters

REST request parameters cannot be considered safe simply because they were sent by your JavaScript code. A user can send a request manually with any values. Sanitization removes unwanted content from data, while validation checks whether the values match the expected format and permitted range.

Choose processing based on the parameter type. For a numeric ID, use absint() and verify that the value has not become zero. sanitize_text_field() may be suitable for short text, while email addresses should use sanitize_email() together with is_email(). For parameters with a strictly limited set of values, use a whitelist.

  • absint() — for positive integer identifiers.
  • sanitize_text_field() — for short text values without HTML.
  • sanitize_email() and is_email() — for email addresses.
  • validate_callback — for checking ranges, formats, dates, and permitted values.
  • Do not use sanitization as a substitute for SQL preparation, capability checks, or HTML escaping.
register_rest_route('site/v1', '/orders', [
    'methods'  => WP_REST_Server::READABLE,
    'callback' => 'site_get_orders',
    'permission_callback' => function () {
        return current_user_can('manage_woocommerce');
    },
    'args' => [
        'page' => [
            'default'           => 1,
            'sanitize_callback' => 'absint',
            'validate_callback' => function ($value) {
                return (int) $value >= 1;
            },
        ],
        'status' => [
            'sanitize_callback' => 'sanitize_text_field',
            'validate_callback' => function ($value) {
                return in_array($value, ['pending', 'processing', 'completed'], true);
            },
        ],
    ],
]);

Build Responses According to the Principle of Minimal Necessary Data

Even if a user is authorized to call an endpoint, that does not mean they need every field of the object. Returning a raw WP_Post, the result of get_users(), a complete order, or an array of metadata fields may expose email addresses, phone numbers, internal identifiers, system flags, and other data.

Create an explicit response array containing only the fields required by the particular client. This approach improves security and API stability while giving you better control over changes to the data structure.

Be especially careful when working with user_meta, post_meta, and order data. Do not return passwords, tokens, integration keys, or values used for the site's internal logic.

  • Define the endpoint contract: specify the fields, types, and formats it returns.
  • Do not pass WordPress objects into the response without explicitly transforming them.
  • Use pagination for lists and set a reasonable limit on the number of records.
  • Return clear error codes and messages without stack traces, SQL queries, or internal paths in production.
  • Separate administrative and public endpoints, even when they work with the same type of data.
function site_format_user(WP_User $user) {
    return [
        'id'          => (int) $user->ID,
        'displayName' => $user->display_name,
        'avatar'      => get_avatar_url($user->ID),
    ];
}

// Не повертайте весь об'єкт $user або результат get_users() без фільтрації.

Secure Filters, Sorting, and Search

An endpoint that returns a data list often becomes problematic not because of authorization itself, but because of uncontrolled filters. Parameters such as orderby, order, search, or meta_key may allow users to retrieve more data than intended, create excessive load, or expose internal fields.

Do not pass arbitrary values directly into WP_Query or a custom SQL query. Use a predefined list for sorting and statuses. If SQL is required, use $wpdb->prepare() and do not build the query by concatenating strings from the user request.

For large or expensive queries, add limits for page, per_page, search-string length, and the number of results. Where appropriate, cache public responses, but do not mix data belonging to different users in the same cache.

  • Limit per_page and do not allow the client to increase the limit arbitrarily.
  • Use a whitelist for orderby and order.
  • Do not allow filtering by internal meta_key values without a clear need.
  • Check that the endpoint does not return posts with draft, private, or trash statuses.
  • For sensitive routes, consider rate limiting at the server, proxy, or dedicated security-solution level.

Test the Endpoint in Different Scenarios

Testing only in a browser while logged in as an administrator does not show the actual level of protection. The same route should be tested without authentication, as a regular user, as a user with the required capability, and as an administrator.

Check not only the HTTP status code but also the response body. Access should be denied before unnecessary data is loaded, and the message should not disclose internal information. Separately test attempts to access another user's ID, parameter tampering, large values, empty strings, and unexpected data types.

  • Unauthenticated request: a private endpoint should return a denial, not a partial list.
  • Regular user: access should be allowed only within the user's capabilities and own objects.
  • User with the required capability: the response should contain only the specified fields.
  • Administrator: expanded access should not accidentally expose internal data.
  • Another user's identifier: the request must not return an object merely because the ID is valid.
  • Invalid parameters: the endpoint should return a predictable error rather than a PHP warning or internal exception.
curl -i https://example.com/wp-json/site/v1/orders

curl -i \
  -H 'X-WP-Nonce: ваш_nonce' \
  'https://example.com/wp-json/site/v1/orders?page=1&status=processing'

# Також перевірте запит із cookie звичайного користувача
# та спробуйте змінити ID на об'єкт іншого користувача.

Common Mistakes When Securing the REST API

Most leaks result from several recurring decisions: allowing everyone access for frontend convenience, checking only whether the user is logged in, or returning the complete result of a WordPress function. These mistakes may remain unnoticed until the endpoint is actively scanned.

Security must be built into the route, the specific operation, and every object. Do not rely on the endpoint being called only by your interface: any client can construct an HTTP request independently.

  • permission_callback => __return_true for private data.
  • Checking is_user_logged_in() without checking capabilities.
  • Checking a role instead of a capability, making permissions less flexible.
  • Failing to verify resource ownership.
  • Returning get_users(), WP_Post, a complete WC_Order, or all metadata fields.
  • Using request parameters without sanitize_callback and validate_callback.
  • No pagination, limits, or whitelist for filters.
  • Trying to protect an endpoint only with a nonce, CORS, a secret URL, or the route name.

Frequently asked questions

Can permission_callback remain __return_true for a GET request?

Yes, but only if the endpoint is genuinely public and every field in the response is intended for anonymous access. The GET method does not make data safe by itself. Personal, commercial, or internal information requires permission checks.

Is checking is_user_logged_in() enough?

No. This check only shows that the user is authenticated. It does not confirm permission for a specific action or object. Use current_user_can() and, when necessary, verify resource ownership.

Does a REST API nonce protect against all attacks?

No. With cookie authentication, a nonce helps protect against CSRF, but it does not replace capability checks, ownership checks, parameter validation, or secure response construction. It is one security measure, not complete authorization.

Is it safe to return the result of get_users() or WP_Post?

Usually not. Such objects may contain more fields than the client needs, including email addresses, internal properties, or metadata. Build an explicit array of permitted fields and do not pass raw objects.

Which response status should be used when a user lacks access?

A 401 status is generally used for an unauthenticated request, while 403 is used for an authenticated user who lacks the required permissions. If the resource should not be disclosed to a particular user, 404 may be appropriate in some scenarios so that its existence is not confirmed.

Conclusion

A secure custom REST endpoint is more than the correct URL and callback. You need to define the route's audience, check capabilities and permission for the specific object, validate parameters, and return only the minimum necessary data.

After every change, test the endpoint without authentication, with different roles, and with manipulated parameters. If differences in responses are expected and controlled rather than dependent on accidental conditions, the risk of data leakage is significantly lower.

← All WordPress materials