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.
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.