Skip to content
System nominalUTC --:--:--Articles 080
MODULE 04 Lesson 12 / 18 35 MIN

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.

KeyWhat core does with it
typeChecks the value against the JSON Schema type. With no sanitize_callback it also selects the sanitiser, rest_parse_request_arg.
requiredAbsent parameter means rest_missing_callback_param and status 400, before anything else runs.
defaultFills in an omitted parameter, as written. Never passes through sanitize_callback.
sanitize_callbackCalled with the raw value for every parameter the request supplied. Its return value is what your callback reads.
validate_callbackCalled before the sanitiser, with the same raw value. Returns false or a WP_Error for a 400. Supplying one replaces the type check.
enumConstrains 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.

Lab 45 minutes

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.