Custom REST Endpoints Without Leaving a Hole Open

permission_callback is required, and __return_true is a decision not a placeholder. Namespaces, argument validation, and what a public endpoint really means.

Secure WordPress REST API endpoint flow with validation, permission checks, and HTTP responses

The REST API is the easiest way to expose a piece of your site to a script, an app or a block editor component. It is also the easiest way to expose a piece of your site to everyone, because an endpoint is public by default in the sense that matters: if you do not decide who may call it, WordPress does not decide for you.

One argument carries that decision, and it is required.

The minimum, done properly

add_action( 'rest_api_init', function () {

    register_rest_route(
        'acme/v1',
        '/items/(?P<id>\d+)',
        array(
            'methods'             => WP_REST_Server::READABLE,
            'callback'            => 'acme_get_item',
            'permission_callback' => function ( WP_REST_Request $request ) {
                return current_user_can( 'read' );
            },
            'args'                => array(
                'id' => array(
                    'required'          => true,
                    'validate_callback' => static function ( $value ) {
                        return is_numeric( $value ) && (int) $value > 0;
                    },
                    'sanitize_callback' => 'absint',
                ),
            ),
        )
    );
} );

Four things are load bearing.

rest_api_init is the hook. Registering anywhere else does nothing, because the route registry is built at that point in the request. Registering on init is a common and silent mistake.

The namespace is vendor/version. acme/v1, not acme. Version it from the start. When the response shape has to change, acme/v2 lets both live side by side, and the alternative is breaking whatever is already calling you.

permission_callback is not optional. Omitting it produces a notice and is treated as an error condition, precisely because the omission used to be silent and used to produce open endpoints.

args is where validation belongs, not at the top of your handler. validate_callback runs first and can reject the request before your code sees it; sanitize_callback then normalizes what survived.

__return_true is a decision

You will see this everywhere:

'permission_callback' => '__return_true',

It is legitimate. It means “anyone on the internet may call this, without logging in.” That is the right answer for a genuinely public read endpoint, and the wrong answer for everything else.

The trouble is that it is also what people write to make the notice go away. If your endpoint reads private data, writes anything, sends anything, or costs money to run, __return_true is not a placeholder you will come back to. It is the finished configuration, shipped.

Two questions before you use it:

  1. If a stranger called this a thousand times a minute, what would happen?
  2. Does the response contain anything that is not already on a public page?

If either answer is uncomfortable, write a real callback.

What a real callback looks like

'permission_callback' => static function ( WP_REST_Request $request ) {
    $id = (int) $request['id'];

    if ( ! current_user_can( 'edit_post', $id ) ) {
        return new WP_Error(
            'acme_forbidden',
            __( 'You cannot edit this item.', 'acme' ),
            array( 'status' => 403 )
        );
    }
    return true;
},

Return true, false, or a WP_Error. A WP_Error with a status in its data is better than false, because the caller gets a message and a status code rather than a generic refusal.

Check the object, not just the general capability. edit_posts is “can edit posts”. edit_post with an id is “can edit this one”. An endpoint that takes an id and checks only the general capability lets any author edit any other author’s work.

The permission callback runs before the main callback. Do not repeat the check in the handler; do not skip it in the permission callback because the handler “checks anyway”.

Read and write are different doors

methods accepts READABLE (GET), CREATABLE (POST), EDITABLE (POST, PUT, PATCH) and DELETABLE, or a plain string.

Register them as separate routes with separate permission callbacks rather than one route accepting everything:

register_rest_route( 'acme/v1', '/items', array(
    array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'acme_list_items',
        'permission_callback' => '__return_true',          // deliberately public
    ),
    array(
        'methods'             => WP_REST_Server::CREATABLE,
        'callback'            => 'acme_create_item',
        'permission_callback' => static function () {
            return current_user_can( 'edit_posts' );
        },
    ),
) );

A single permissive callback covering both is how a public read endpoint quietly becomes a public write endpoint.

Authentication is not your problem, authorization is

By the time your permission callback runs, WordPress has already worked out who the caller is. Cookie authentication is the built-in path for same-site JavaScript, and it requires a nonce for the wp_rest action sent as the X-WP-Nonce header. Without that header, a cookie-authenticated request is treated as logged out, and your capability check correctly fails. That is the most common cause of a “why does this work in the browser but not from my script” report.

For calls from outside the site, application passwords and dedicated authentication plugins exist. Your endpoint does not need to know which was used. It asks current_user_can and gets an answer.

Responses and errors

Return data, or a WP_REST_Response when you need to set a status or headers, or a WP_Error when something failed:

function acme_get_item( WP_REST_Request $request ) {
    $item = acme_find( (int) $request['id'] );

    if ( ! $item ) {
        return new WP_Error( 'acme_not_found', __( 'Not found.', 'acme' ),
                             array( 'status' => 404 ) );
    }
    return rest_ensure_response( $item );
}

Do not echo and do not wp_die inside a REST callback. Both corrupt the JSON response, and the caller gets a parse error instead of your message.

Escape nothing on the way out of a REST endpoint in the HTML sense: JSON encoding is the escaping. What you do still owe is not leaking fields the caller should not see. Build the response array explicitly rather than returning a whole database row.

Calling it from a browser somewhere else

If your endpoint is meant to be called from another origin, that is a browser policy question rather than a WordPress one, and it fails in the browser console with a message about the request being blocked rather than anywhere in your PHP. Which side is responsible, and what the preflight request is actually asking for, is worked through in CORS errors explained from both ends of the request.

The short version

  • Register on rest_api_init. Namespace as vendor/v1.
  • permission_callback is required and __return_true is a decision, not a placeholder.
  • Check the object, not just the capability, whenever the route takes an id.
  • Separate routes for read and write, with separate callbacks.
  • Validate and sanitize in args, not in the handler.
  • Return WP_Error with a status. Never echo.
Written by

Shah Alom

Shah Alom is the founder and writer behind Ebuhu, where he covers PHP, WordPress development, plugin and theme development, debugging, practical programming techniques, and AI-assisted coding. Drawing on hands-on web development experience, he focuses on clear, practical guidance that helps developers understand how things work, avoid common mistakes, and write more reliable code.

Leave a Reply

Your email address will not be published. Required fields are marked *