Skip to content
System nominalUTC --:--:--Articles 041
MODULE 03 Lesson 09 / 09 30 MIN

Authorisation

REST Permissions and Object-Level Checks

Why a REST route without permission_callback runs for anyone who reaches it, and how to test a route is protected instead of assuming it.

A route is registered, tested in the browser while logged in as an administrator, and shipped. The permission callback is either missing or is is_user_logged_in(), and neither one shows up in that test, because the administrator passes both. The finding arrives later, usually from an access log, when an account with no business calling the route starts calling it with different IDs.

REST is the one entry point in WordPress where the framework could refuse to run a route that declares no permission check, and it does not. That decision is old and deliberate, and it means the guard is a property of your registration rather than of the request pipeline. The same is true of the second half of the problem: knowing who is calling is not the same as knowing what they may do.

Everything in this lesson is testable from a terminal. Four curl calls answer the question "is this route protected" without reading the source twice.

Dispatch runs the callback only if a permission callback exists

This block in WP_REST_Server decides whether your route runs. The condition on the second line is the whole story:

// wp-includes/rest-api/class-wp-rest-server.php, WordPress 7.0.7.
if ( ! is_wp_error( $response ) && ! empty( $handler['permission_callback'] ) ) {
    $permission = call_user_func( $handler['permission_callback'], $request );

    if ( is_wp_error( $permission ) ) {
        $response = $permission;
    } elseif ( false === $permission || null === $permission ) {
        $response = new WP_Error(
            'rest_forbidden',
            __( 'Sorry, you are not allowed to do that.' ),
            array( 'status' => rest_authorization_required_code() )
        );
    }
}

Three consequences. When permission_callback is absent or empty, the check is skipped and the route callback runs for every request that reaches dispatch, including a request with no credentials at all. When the callback returns false or null, core builds its own rest_forbidden error with the status from rest_authorization_required_code(). And when it returns a WP_Error, that error is the response, so the code and message you choose are what the client sees.

Core does complain, once, at registration time. Since WordPress 5.5, register_rest_route() fires a _doing_it_wrong() notice when an argument group has no permission_callback, naming __return_true as what to pass when the route really is public. That is a notice, not a refusal. With WP_DEBUG on, a REST request to such a route carries an X-WP-DoingItWrong response header, which makes that header the fastest way to find this class of bug on a site you control.

Run against the WordPress 7.0.7 REST server, the difference between the two registrations is visible immediately. Both routes below update a shipment by ID. The first has no permission callback; the second checks the object.

POST /acme/v1/shipments/10        (no permission_callback)
  logged-out caller                            200 {"id":10,"updated":true}
  subscriber                                   200 {"id":10,"updated":true}
  administrator                                200 {"id":10,"updated":true}

POST /acme/v1/guarded/10          (permission_callback checks the object)
  logged-out caller                            401 acme_forbidden
  subscriber who is not the owner              403 acme_forbidden
  owner of shipment 10                         200 {"id":10,"updated":true}

The first route is public in the most literal sense. Nothing in the request pipeline asked whether the caller should be there, and the callback that performs the write ran anyway.

Authentication answers who, and authorisation is the missing half

The check people write when they are thinking about attackers is is_user_logged_in(). It answers one question: does this request carry a valid session for some account. It does not say which account, or what that account may do.

With the default roles, the subscriber role holds read and nothing else, and a subscriber can log in. A route guarded by a login check is therefore open to every account on the site, including accounts created by checkout, by a membership plugin, or by an open registration form somebody enabled and forgot to close. On a site that sells anything, "any account" is a population you do not control.

Authentication and authorisation are separate decisions, and a route needs both answers in writing. The authentication half is handled for you most of the time: a cookie with a valid session, an application password, or a request with no credentials that core treats as anonymous. The authorisation half is the permission callback, and it has to name the capability and the object.

// Fragment of a fictional plugin. Not runnable on its own.
'permission_callback' => static function ( WP_REST_Request $request ) {
    // Authentication: who is calling. A grant for anonymous callers is a
    // deliberate decision, and '__return_true' says so in one word.
    if ( ! is_user_logged_in() ) {
        return new WP_Error(
            'acme_unauthorised',
            'Log in to continue.',
            array( 'status' => rest_authorization_required_code() )
        );
    }

    // Authorisation: what this caller may do.
    if ( ! current_user_can( 'edit_acme_shipments' ) ) {
        return new WP_Error(
            'acme_forbidden',
            'Your account cannot edit shipments.',
            array( 'status' => rest_authorization_required_code() )
        );
    }

    return true;
},

Both errors use the same helper and produce different statuses, because rest_authorization_required_code() returns 401 when nobody is logged in and 403 when somebody is. That is the distinction a client needs: 401 means "authenticate and retry", 403 means "your credentials are fine and the answer is still no". A client that receives 403 for a missing session and retries the login is chasing a problem it cannot fix.

A route with an ID in the path needs an object-level check

Nothing in that callback depends on how the caller authenticated, which is the right shape. REST settles authentication before dispatch: a cookie session with the X-WP-Nonce header checked by core, an application password in the Authorization header presented by a script or a CI job, or nothing at all for an anonymous request. All three arrive at the same permission callback with wp_get_current_user() already set. An application password does not skip the callback, and it does not turn a missing capability into a granted one.

The capability check above answers "may this account edit shipments in general". A route whose path contains an ID raises a second question, and the two are not the same question. Consider a route that accepts a shipment ID and writes a note onto it. The general capability is necessary and nowhere near sufficient: an account that may edit some shipments may not edit this one.

The route pattern puts the ID in the request, so the permission callback can resolve the object before anything is written. This is the shape core uses in its own controllers, where check_update_permission() ends in current_user_can( 'edit_post', $post->ID ), which passes the resolved object's capability question through map_meta_cap().

// Fragment of a fictional plugin. Not runnable on its own.
add_action( 'rest_api_init', 'acme_register_shipment_note_route' );

function acme_register_shipment_note_route() {
    register_rest_route(
        'acme/v1',
        '/shipments/(?P<id>\d+)/note',
        array(
            'methods'             => WP_REST_Server::CREATABLE,
            'callback'            => 'acme_save_shipment_note',
            'permission_callback' => 'acme_can_save_shipment_note',
            'args'                => array(
                'id'   => array(
                    'required'          => true,
                    'sanitize_callback' => 'absint',
                ),
                'note' => array(
                    'required'          => true,
                    'sanitize_callback' => 'sanitize_text_field',
                ),
            ),
        )
    );
}

function acme_can_save_shipment_note( WP_REST_Request $request ) {
    // Resolve the object from the route parameter, not from a body field.
    $shipment = acme_get_shipment( absint( $request['id'] ) );

    if ( ! $shipment ) {
        return new WP_Error(
            'acme_shipment_not_found',
            'No such shipment.',
            array( 'status' => 404 )
        );
    }

    // The object question: may this user edit this shipment?
    if ( ! current_user_can( 'edit_acme_shipment', $shipment->id ) ) {
        return new WP_Error(
            'acme_forbidden',
            'You cannot edit this shipment.',
            array( 'status' => rest_authorization_required_code() )
        );
    }

    return true;
}

Two details in that function decide whether it holds. The object comes from the URL parameter rather than a body field, so the thing being authorised and the thing being written are the same row. And the capability passed to current_user_can() is the object-level one with the ID attached, so map_meta_cap() resolves it against that shipment instead of answering a question about shipments in general. A callback that checks current_user_can( 'edit_acme_shipments' ) and then writes to whatever ID arrived is authorised and still wrong.

The ordering is worth knowing because it limits what a permission callback can assume. Dispatch matches the route, validates the arguments against the args schema, and only then calls the permission callback. So $request['id'] is already the absint integer by the time you read it, and a request with a malformed parameter fails validation before your callback runs. Use the schema for shape and the callback for permission, and do not reimplement one in the other.

Test the route with four callers, not one

The test that finds these bugs is a short list of callers, and the administrator is not the interesting one. With an application password or a logged-in cookie, the four calls are:

BASE=https://example.test/wp-json/acme/v1

# 1. No credentials at all.
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$BASE/guarded/10"

# 2. A subscriber who does not own shipment 10.
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$BASE/guarded/10" \
     -u 'subscriber:xxxx xxxx xxxx xxxx xxxx xxxx'

# 3. The account that does own shipment 10.
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$BASE/guarded/10" \
     -u 'owner:xxxx xxxx xxxx xxxx xxxx xxxx'

# 4. An ID that does not exist, with the owner's credentials.
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$BASE/guarded/999999" \
     -u 'owner:xxxx xxxx xxxx xxxx xxxx xxxx'

The expected answers are 401, 403, 200 and 404 or 403. A 200 on the first line means the route has no permission callback or one that returns true for everyone. A 200 on the second means the check tests authentication or a general capability rather than the object. A 500 anywhere means the callback assumed a value that validation had not yet produced, which usually means it read a body field that the schema never declared.

Those four calls are also the cheapest regression test you can keep in a plugin. A PHPUnit case with wp_set_current_user() and a dispatched WP_REST_Request asserts the same four statuses without a running web server, and it fails the moment somebody widens a callback to make a different test pass.

The methodical version is to break the guard on purpose and confirm the test notices. Delete the permission callback, run the four calls, and check that the first two change status. A test that still passes when the guard is removed is testing something else.

One place this question is often answered in the wrong layer is a global filter. The rest_pre_dispatch filter runs before route matching, so a filter on it can refuse every request on the site, which suits a maintenance block or an IP allowlist. It is the wrong place for a per-route rule, because at that point nothing has looked at the route, the arguments or the capability the operation needs. A site-wide hook that returns an error for "unprotected" requests looks like a safety net and hides the routes that are actually missing a callback.

The audit is a loop over the registered routes, and WP-CLI runs it across the whole plugin set in one command:

wp eval '
foreach ( rest_get_server()->get_routes() as $route => $handlers ) {
    foreach ( $handlers as $handler ) {
        if ( empty( $handler["methods"] ) ) {
            continue;
        }
        printf(
            "%-8s %-46s %s\n",
            implode( ",", array_keys( $handler["methods"] ) ),
            $route,
            empty( $handler["permission_callback"] ) ? "NO PERMISSION CALLBACK" : "guarded"
        );
    }
}'

Read the output for routes that say NO PERMISSION CALLBACK, then read the guarded ones for what they actually check. The requirement to write in a review is not "does this route have a permission callback" but "does the callback name the object". A route with no ID in its path has nothing to resolve, and a route with one needs both checks.

Lab 35 minutes

Below is a route from a fictional plugin that reads a shipment by ID. Write the permission callback it needs, then write the test that proves the callback works.

register_rest_route(
    'acme/v1',
    '/shipments/(?P<id>\d+)',
    array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'acme_get_shipment_endpoint',
        'permission_callback' => '__return_true',
        'args'                => array(
            'id' => array( 'required' => true, 'sanitize_callback' => 'absint' ),
        ),
    )
);

The business rule is that a shipment is visible to its owner, to any account holding acme_read_shipments, and to nobody else.

Write the callback so it returns the right thing for an anonymous caller, an account without the capability, the owner, a capability holder who is not the owner, and an ID that does not exist. Then write the curl calls, and say which line of your test would fail if somebody changed '__return_true' back.

Show the solution and the reasoning
// Fragment of a fictional plugin. Not runnable on its own.
register_rest_route(
    'acme/v1',
    '/shipments/(?P<id>\d+)',
    array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'acme_get_shipment_endpoint',
        'permission_callback' => 'acme_can_read_shipment',
        'args'                => array(
            'id' => array( 'required' => true, 'sanitize_callback' => 'absint' ),
        ),
    )
);

function acme_can_read_shipment( WP_REST_Request $request ) {
    $shipment = acme_get_shipment( absint( $request['id'] ) );

    if ( ! $shipment ) {
        return new WP_Error(
            'acme_shipment_not_found',
            'No such shipment.',
            array( 'status' => 404 )
        );
    }

    if ( ! is_user_logged_in() ) {
        return new WP_Error(
            'acme_unauthorised',
            'Log in to continue.',
            array( 'status' => rest_authorization_required_code() )
        );
    }

    $is_owner     = (int) $shipment->owner_id === get_current_user_id();
    $is_dispatcher = current_user_can( 'acme_read_shipments' );

    if ( ! $is_owner && ! $is_dispatcher ) {
        return new WP_Error(
            'acme_forbidden',
            'You cannot read this shipment.',
            array( 'status' => rest_authorization_required_code() )
        );
    }

    return true;
}

The anonymous branch comes before the ownership test on purpose. Without it, an anonymous caller is compared against owner_id with get_current_user_id() returning 0, which is correct by accident and only for shipments whose owner column is never zero. A 401 also tells the client something useful, which a 404 does not.

The two capability questions are ordered so that the interesting one comes last. Ownership is a property of the row; acme_read_shipments is a property of the account. Keeping them in named variables means the condition that decides reads as a sentence, and the next person adding a third rule has somewhere to put it.

The expected statuses are 404 for an unknown ID, 401 for no credentials, 403 for an account that is neither the owner nor a dispatcher, and 200 for the owner and for the dispatcher. The line that catches a regression is the 403: change 'permission_callback' => 'acme_can_read_shipment' back to '__return_true' and that request returns 200 with the shipment body, so a test that only checks the owner's success path would keep passing while the route is open to every account on the site.

One trade-off is worth naming, because it is a decision rather than a detail. Returning 404 for an unknown shipment and 403 for a shipment that exists but is not yours tells an authenticated attacker which IDs exist. Collapsing both into 404 hides the enumeration and costs the legitimate client a useful distinction. For most plugins the honest 403 is the better answer, because the attacker who can enumerate shipment IDs can usually do worse things with an account anyway. Pick one, and make the test assert the choice.

Progress

Mark this lesson done and it stays marked in this browser.

Course complete

That is the last lesson of WordPress Security and Hardening. If the material was useful, the practice is what makes it stick: rebuild the final project from an empty directory without looking back at the lesson.