The public side
REST Routes and Permissions
Register a REST route in WordPress 7.0.7 with a namespace, a permission callback and an input schema, and know what each one does when a request arrives.
You add a route, check it in the browser, and it works. You were logged in as an administrator, so it would have worked with no permission check at all. The route goes to a client, and the first thing the client's developer does is call it from a machine with no session. Whatever your callback does for an anonymous caller is what you shipped.
That is the difference between a route that is secure and one that was tested while you
happened to be an administrator. The platform will not catch it for you: WordPress runs a
route whose permission_callback is missing, and the only complaint is a debug
notice most sites never display. Everything here is aimed at the moment you write the
registration, because that is the only place the guard can be attached.
register_rest_route fixes a namespace and a route, and there is no undo
The call takes a namespace, a route and an argument array, and registers them with the
REST server singleton. The first segment after /wp-json/ is the namespace, so
register_rest_route( 'wpsifero/v1', '/orders/(?P<id>\d+)', $args )
answers at /wp-json/wpsifero/v1/orders/7. WordPress 7.0.7 matches the namespace
as a string. It does not parse the v, and it has no idea that
wpsifero/v2 supersedes anything, so the version segment is a promise made at
first release rather than a label you can tidy up later. If the response shape has to change
incompatibly, register the same handler under wpsifero/v2 as well and keep
v1 answering for a stated period; both calls can point at one callback, which
is cheaper than two implementations.
The arguments are validated before anything is registered. An empty namespace, an empty
route, or a namespace with a leading or trailing slash each produce a
_doing_it_wrong() notice, and the first two return false. I called
register_rest_route( '', '/thing', $args ) and got false. I called
it with '/wpsifero/v1/' and the slashes were trimmed, so the route landed at
/wpsifero/v1/thing2 and worked, with the notice still firing. That is how a typo
survives to production: the route answers, so nobody looks at the notice.
There is no supported way to take a route back.
WP_REST_Server in 7.0.7 has register_route() and
get_routes() and no remove_route(), and the function
unregister_rest_route() that turns up in tutorials does not exist anywhere in
the 7.0.7 tree. I checked both with grep across
wp-includes/, and the only way to be rid of a route is to stop registering it.
Registration is additive and irreversible, which is why it belongs in exactly one place.
That place is rest_api_init, not the top of your plugin file. I logged the
hook order for one REST request on 7.0.7 and got plugins_loaded,
init, parse_request, then rest_api_init. Your plugin
file runs before all four, so a register_rest_route() call at file scope
registers before anything else has had a chance to touch the registry, and
register_rest_route() says so with a
_doing_it_wrong() notice: routes must be registered on the
rest_api_init action. The route still answers, but by the time any
rest_api_init callback runs the route is already in the registry in its final
shape, so every later callback reads a fact rather than a decision.
A route with no permission callback runs for anyone who asks
This is the block that decides whether your callback runs. It is from
WP_REST_Server::respond_to_request() in 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() )
);
}
}
Read the condition. When permission_callback is absent or empty, the whole
block is skipped and dispatch goes straight to your callback. The route fails open. I
registered one with a callback that returns array( 'secret' => 'customer list' ),
gave it no permission key, and requested it on 7.0.7 with no cookie and no header:
HTTP/1.1 200 OK
X-WP-DoingItWrong: register_rest_route (since 5.5.0; The REST API route definition for
<code>wpsifero/v1/lab-a-open</code> is missing the required
<code>permission_callback</code> argument. For REST API routes that are intended to be
public, use <code>__return_true</code> as the permission callback.)
{"message":"this ran for an anonymous visitor"}
Both halves of that are worth keeping. The route answered a request that carried no
credentials, and the only warning was a response header. It appears because
WP_DEBUG is on: _doing_it_wrong() writes a PHP notice when
WP_DEBUG is true, and REST requests pass it through
rest_handle_doing_it_wrong(), which sets X-WP-DoingItWrong and
returns early when WP_DEBUG is off. On a production site, nothing surfaces. A
route registered today without a permission callback is public today, and it stays public
through any number of deploys.
"I will add the auth check once the shape of the response settles" is exactly how that
happens. The registration is complete and the route works, so the check is the only thing
left on the list and the one you cannot observe failing. Write
'permission_callback' => '__return_true' when the route really is public. It
silences the notice and records the decision as a decision rather than as an omission.
Validation runs before the permission callback, which leaks more than it should
The order inside WP_REST_Server::dispatch() is fixed. The server calls
$request->has_valid_params(), then $request->sanitize_params(),
and only then hands the request to respond_to_request(), which is where the
permission callback lives. I put a validate_callback and a
permission_callback on the same route, each writing a line to a file, and the
log read validate ran then permission ran.
The practical consequence is that an unauthenticated caller can probe your schema. A
request that omits a required parameter, or sends a string where the schema wants an
integer, comes back 400 with the parameter name in the message before any
permission check is consulted:
$ curl '.../?rest_route=/wpsifero/v1/lab-j-customers'
{"code":"rest_missing_callback_param","message":"Missing parameter(s): customer","data":{"status":400,"params":["customer"]}}
That tells a stranger the route takes a parameter called customer and that it
is required. Most routes have nothing to hide at that level, but one that exists for a small
set of accounts should not describe its own inputs to everyone who guesses the URL. If the
parameter names are sensitive, the permission callback has to be the cheap
is_user_logged_in() check and the object-level check has to happen inside the
callback, after core has validated the shape.
Declare the schema, and do not assume the default went through it
The schema lives in the args key of the route argument array, one entry per
parameter. Six keys do the work.
| Key | What core does with it |
|---|---|
type | Checks the value against the JSON Schema type. With no sanitize_callback it also selects the sanitiser, rest_parse_request_arg. |
required | Absent parameter means rest_missing_callback_param and status 400, before anything else runs. |
default | Fills in an omitted parameter, as written. Never passes through sanitize_callback. |
sanitize_callback | Called with the raw value for every parameter the request supplied. Its return value is what your callback reads. |
validate_callback | Called before the sanitiser, with the same raw value. Returns false or a WP_Error for a 400. Supplying one replaces the type check. |
enum | Constrains the value to a list, producing rest_not_in_enum outside it. |
Two of those rows catch people. A default is not sanitised. I registered
note with 'default' => 'Café & Co' and sent no
note parameter; the callback received the string exactly as written in the
registration. If the default is computed from anything a request can influence, or if it is
markup, sanitise it at the point of use. And a validate_callback of your own
replaces the type validation rather than adding to it. I registered n with
'type' => 'integer' and a validate_callback that only logged and
returned true, sent ?n=abc, and both callbacks received the string
'abc'. The route returned 200. Keep the type check inside a
validator of your own, or let core do it by not adding one.
The order for a parameter the request did send is validate, then sanitise. Both callbacks
see the same raw input, and a value that fails validation never reaches the sanitiser.
?n=007 reached both callbacks as the string '007' and left the
route as the integer 7.
A parameter with no schema is whatever the client sent
Here is a route of the shape that gets written on a Friday. It reads one parameter and interpolates it into a query:
add_action( 'rest_api_init', 'wpsifero_register_customer_search' );
function wpsifero_register_customer_search(): void {
register_rest_route(
'wpsifero/v1',
'/customers',
array(
'methods' => WP_REST_Server::READABLE,
'permission_callback' => '__return_true',
'callback' => 'wpsifero_search_customers',
)
);
}
function wpsifero_search_customers( WP_REST_Request $request ) {
global $wpdb;
$customer = $request['customer'];
$sql = "SELECT id, customer FROM {$wpdb->prefix}wpsifero_orders
WHERE customer LIKE '%{$customer}%'";
return new WP_REST_Response(
array( 'type' => gettype( $customer ), 'sql' => $sql, 'rows' => $wpdb->get_results( $sql, ARRAY_A ) ),
200
);
}
No args key, so has_valid_params() has nothing to check and
sanitize_params() iterates an empty list. The parameter arrives exactly as
curl sent it. I requested ?customer=' OR '1'='1 against this route on 7.0.7:
{"type":"string",
"sql":"SELECT id, customer FROM wp_wpsifero_orders WHERE customer LIKE '%' OR '1'='1%'",
"rows":[{"id":"1","customer":"Ada Lovelace"},{"id":"2","customer":"Grace Hopper"}]}
Adding an args entry does not make a query safe on its own, because a schema
is a contract about types, not a guarantee about content. Against the same route with
'customer' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field' )
the same request came back with no rows: the sanitiser stripped the quotes and
$wpdb->prepare() bound what was left as a string. Four changes make the route
worth shipping: declare the parameter, mark it required, sanitise it, and build the query
with $wpdb->prepare() and esc_like() so a wildcard in a
customer's name does not become a wildcard in the SQL.
A schema closes a second exploit that has nothing to do with quotes. With no declared
type, ?customer[]=a arrives as a PHP array, and I saw gettype()
return array in the callback. PHP stringified it with a warning and the query
ran on the literal text Array. A handler that passes the value to a function
expecting a string gets a fatal TypeError instead, which I reproduced by
validating the same array with strlen(). Declare
'type' => 'string' and the array is rejected with
rest_invalid_type and status 400 before your code sees it.
Path parameters are not exempt. A pattern such as
'/orders/(?P<id>\d+)' constrains what matches the URL, but the value still
arrives as a string and only becomes an integer if you declare id in
args.
get_param can return a different value than the route matched
WP_REST_Request::get_parameter_order() returns JSON, then POST body, then GET,
then URL, then defaults, and get_param() returns the first match in that order.
URL parameters are checked after GET, so a parameter named in the path can be overridden by a
query string value with the same name.
I registered '/lab-k-order/(?P<id>\d+)' with a permission callback
that authorises against $request->get_url_params()['id'] and a callback that
reads $request->get_param( 'id' ). Requesting
/lab-k-order/1?id=2 passed the permission check, because the URL said
1, and then answered with the data for 2:
{"authorised_for":1,"your_data":2}
An object-level check that reads one source and a handler that reads another is an
authorisation bypass with no cleverness required. Pick one reader and use it in both
places; I use get_param() in both and declare the parameter in
args, so the value is validated and coerced on the way in.
Return a response or an error, and pick the status deliberately
A callback may return a WP_REST_Response, a plain array, or a
WP_Error. rest_ensure_response() wraps an array in a
WP_REST_Response with status 200; a WP_Error passes through
untouched and becomes the HTTP response. Reach for WP_REST_Response when there
is something to say beyond the body, because it is where status codes, headers and links
live. I set a custom header and a link on one and read both back on the wire, as
X-WPSifero-Total: 2 and a Link header from
add_link( 'next', ... ).
For failures, the error data decides the status. A WP_Error carrying
array( 'status' => 404 ) produced 404 and a body of
{"code":"wpsifero_not_found","message":"No such order.","data":{"status":404}}.
The same error with no status produced 500, because
rest_convert_error_to_response() starts from 500 and only changes it when an
error datum has a numeric status. A missing status is not a default 200 or a
guessed 400; it is a server error, and a client will file it as your bug.
When the caller is not allowed, use rest_authorization_required_code()
rather than hardcoding a number. It is two lines:
function rest_authorization_required_code() {
return is_user_logged_in() ? 403 : 401;
}
Returning false from a permission callback hands core's own
rest_forbidden error that status. Returning a WP_Error of your own
lets you choose the code and message. I confirmed both paths with a resolved user: the same
route answered 401 with no identity and 403 once a user was
resolved. One more shape worth knowing, since it looks correct and is not:
new WP_REST_Response( new WP_Error( ... ), 200 ) serialises the error as data,
so I got HTTP/1.1 200 OK with
{"errors":{"wpsifero_nope":["Wrapped."]},"error_data":[]}. Return the
WP_Error itself instead of wrapping it.
Build a route wpsifero/v1/orders/(?P<id>\d+) that returns one row
from the wpsifero_orders table from module 2. It requires the
edit_posts capability, and id must be a positive integer
declared in args and built into the query with $wpdb->prepare().
Return 404 with a WP_Error when the row does not exist.
Then answer four questions. What does the registration return if the namespace is
the empty string, and what does the route do without a permission_callback?
What status does an anonymous caller get from a permission callback that returns
false, and what changes when that caller is logged in? Which of
validate_callback, sanitize_callback and the
default value run for a parameter the request never sent? And why does a
query string of ?id=999 matter on a route whose pattern matched
/1?
Show the solution and the reasoning
add_action( 'rest_api_init', 'wpsifero_register_order_route' );
function wpsifero_register_order_route(): void {
register_rest_route(
'wpsifero/v1',
'/orders/(?P<id>\d+)',
array(
'methods' => WP_REST_Server::READABLE,
'permission_callback' => function ( WP_REST_Request $request ) {
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error(
'wpsifero_forbidden',
__( 'You cannot read orders.', 'wpsifero' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
},
'callback' => 'wpsifero_get_order',
'args' => array(
'id' => array(
'type' => 'integer',
'required' => true,
'validate_callback' => function ( $value ) {
return is_numeric( $value ) && (int) $value > 0;
},
'sanitize_callback' => 'absint',
),
),
)
);
}
function wpsifero_get_order( WP_REST_Request $request ) {
global $wpdb;
$id = (int) $request->get_param( 'id' );
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT id, customer, total FROM {$wpdb->prefix}wpsifero_orders WHERE id = %d",
$id
),
ARRAY_A
);
if ( ! $row ) {
return new WP_Error(
'wpsifero_order_not_found',
__( 'No such order.', 'wpsifero' ),
array( 'status' => 404 )
);
}
$response = new WP_REST_Response( $row, 200 );
$response->add_link( 'self', rest_url( 'wpsifero/v1/orders/' . $id ) );
return $response;
}
An empty namespace returns false and fires a
_doing_it_wrong() notice naming the namespace rule. The route is not
registered at all. Without a permission_callback, the route runs for every
request that reaches it, including one with no credentials; I proved that with curl and
got 200 plus an X-WP-DoingItWrong header, which only appears
while WP_DEBUG is on. The route in the solution returns a
WP_Error rather than false for one reason: it gives the client a
code of its own, wpsifero_forbidden, instead of core's generic
rest_forbidden. Both produce the same status, because both call
rest_authorization_required_code(). That status is 401 for a
caller with no session, and 403 once WordPress has resolved a user, which is
the distinction a client needs in order to know whether to re-authenticate or give up.
For a parameter the request never sent, validate_callback and
sanitize_callback both skip it, because has_valid_params() and
sanitize_params() both iterate the parameters present in the request. The
default is then copied from the registration into the request as-is, with no
validation and no sanitising, so a default is trusted code and should look like it. With
id declared required and no default, a request that omits it is
answered by core with rest_missing_callback_param and status 400 before the
permission callback is consulted.
The query string matters because of the parameter order. The pattern matched
/orders/1 and the URL parameter id is 1, but
get_param( 'id' ) checks GET before URL, so ?id=999 is what the
callback reads. On this route that is what the schema and
$wpdb->prepare() are for: the value is coerced to an integer and bound as
%d, so the worst case is a 404. On a route whose permission callback reads
get_url_params()['id'] while the handler reads
get_param( 'id' ), the same query string is an authorisation bypass. Read the
value the same way in both places. I use get_param() in both and declare the
parameter in args.
Everything here was executed against WordPress 7.0.7 on PHP 8.5.6 through
php -S on 127.0.0.1: the fail-open request and its header, the
401 and 403 responses, the validate-then-sanitise traces, the parameter precedence bypass,
the injection result with and without a schema, and each error shape. The dispatch
condition, the parameter order, the default status of 500 in
rest_convert_error_to_response(), and the absence of
unregister_rest_route() were read in the 7.0.7 source.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.