diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php
index fbe1eda4a4..38dd6527b6 100644
--- a/src/wp-includes/option.php
+++ b/src/wp-includes/option.php
@@ -1907,7 +1907,8 @@ function register_initial_settings() {
 		'blogname',
 		array(
 			'show_in_rest' => array(
-				'name' => 'title',
+				'name'   => 'title',
+				'public' => true,
 			),
 			'type'         => 'string',
 			'description'  => __( 'Site title.' ),
@@ -1919,7 +1920,8 @@ function register_initial_settings() {
 		'blogdescription',
 		array(
 			'show_in_rest' => array(
-				'name' => 'description',
+				'name'   => 'description',
+				'public' => true,
 			),
 			'type'         => 'string',
 			'description'  => __( 'Site tagline.' ),
@@ -2091,6 +2093,7 @@ function register_initial_settings() {
  *
  * @since 2.7.0
  * @since 4.7.0 `$args` can be passed to set flags on the setting, similar to `register_meta()`.
+ * @since 5.4.0 Added the `$public` parameter to the `$show_in_rest` member of `$args`.
  *
  * @global array $new_whitelist_options
  * @global array $wp_registered_settings
@@ -2101,12 +2104,19 @@ function register_initial_settings() {
  * @param array  $args {
  *     Data used to describe the setting when registered.
  *
- *     @type string   $type              The type of data associated with this setting.
- *                                       Valid values are 'string', 'boolean', 'integer', and 'number'.
- *     @type string   $description       A description of the data attached to this setting.
- *     @type callable $sanitize_callback A callback function that sanitizes the option's value.
- *     @type bool     $show_in_rest      Whether data associated with this setting should be included in the REST API.
- *     @type mixed    $default           Default value when calling `get_option()`.
+ *     @type string     $type              The type of data associated with this setting.
+ *                                         Valid values are 'string', 'boolean', 'integer', and 'number'.
+ *     @type string     $description       A description of the data attached to this setting.
+ *     @type callable   $sanitize_callback A callback function that sanitizes the option's value.
+ *     @type bool|array $show_in_rest {
+ *         Whether data associated with this setting should be included in the REST API. Optionally passing
+ *         an array will include the setting in the REST API, with additional optional configuration.
+ *
+ *         @type string $name   The name to display this setting in the REST API as. Default `$option_name`.
+ *         @type array  $schema Additional schema data to be included for this option.
+ *         @type bool   $public Whether this option can be shown to all users. Default false.
+ *     }
+ *     @type mixed      $default           Default value when calling `get_option()`.
  * }
  */
 function register_setting( $option_group, $option_name, $args = array() ) {
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php
index c5cf1a7a4b..2342178cac 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php
@@ -40,16 +40,15 @@ class WP_REST_Settings_Controller extends WP_REST_Controller {
 			'/' . $this->rest_base,
 			array(
 				array(
-					'methods'             => WP_REST_Server::READABLE,
-					'callback'            => array( $this, 'get_item' ),
-					'args'                => array(),
-					'permission_callback' => array( $this, 'get_item_permissions_check' ),
+					'methods'  => WP_REST_Server::READABLE,
+					'callback' => array( $this, 'get_item' ),
+					'args'     => array(),
 				),
 				array(
 					'methods'             => WP_REST_Server::EDITABLE,
 					'callback'            => array( $this, 'update_item' ),
 					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
-					'permission_callback' => array( $this, 'get_item_permissions_check' ),
+					'permission_callback' => array( $this, 'update_item_permissions_check' ),
 				),
 				'schema' => array( $this, 'get_public_item_schema' ),
 			)
@@ -58,14 +57,14 @@ class WP_REST_Settings_Controller extends WP_REST_Controller {
 	}
 
 	/**
-	 * Checks if a given request has access to read and manage settings.
+	 * Checks if a given request has access to manage settings.
 	 *
-	 * @since 4.7.0
+	 * @since 5.4.0
 	 *
 	 * @param WP_REST_Request $request Full details about the request.
-	 * @return bool True if the request has read access for the item, otherwise false.
+	 * @return bool True if the request has write access for the item, otherwise false.
 	 */
-	public function get_item_permissions_check( $request ) {
+	public function update_item_permissions_check( $request ) {
 		return current_user_can( 'manage_options' );
 	}
 
@@ -212,9 +211,11 @@ class WP_REST_Settings_Controller extends WP_REST_Controller {
 	 *
 	 * @since 4.7.0
 	 *
+	 * @param bool $return_non_public Optional. Returns options that are set with `show_in_rest`,
+	 *                                but aren't marked public. Default false.
 	 * @return array Array of registered options.
 	 */
-	protected function get_registered_options() {
+	protected function get_registered_options( $return_non_public = false ) {
 		$rest_options = array();
 
 		foreach ( get_registered_settings() as $name => $args ) {
@@ -228,6 +229,11 @@ class WP_REST_Settings_Controller extends WP_REST_Controller {
 				$rest_args = $args['show_in_rest'];
 			}
 
+			// Users without manage_options can only see settings marked public.
+			if ( ! $return_non_public && ! current_user_can( 'manage_options' ) && empty( $rest_args['public'] ) ) {
+				continue;
+			}
+
 			$defaults = array(
 				'name'   => ! empty( $rest_args['name'] ) ? $rest_args['name'] : $name,
 				'schema' => array(),
@@ -277,7 +283,7 @@ class WP_REST_Settings_Controller extends WP_REST_Controller {
 			return $this->add_additional_fields_schema( $this->schema );
 		}
 
-		$options = $this->get_registered_options();
+		$options = $this->get_registered_options( true );
 
 		$schema = array(
 			'$schema'    => 'http://json-schema.org/draft-04/schema#',
diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php
index 73805b4d38..d944af3e1e 100644
--- a/tests/phpunit/tests/rest-api/rest-settings-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php
@@ -54,17 +54,41 @@ class WP_Test_REST_Settings_Controller extends WP_Test_REST_Controller_Testcase
 	public function test_context_param() {
 	}
 
-	public function test_get_item_is_not_public_not_authenticated() {
+	public function test_get_item_shows_public_items_not_authenticated() {
 		$request  = new WP_REST_Request( 'GET', '/wp/v2/settings' );
 		$response = rest_get_server()->dispatch( $request );
-		$this->assertEquals( 401, $response->get_status() );
+		$data     = $response->get_data();
+		$actual   = array_keys( $data );
+
+		$expected = array(
+			'title',
+			'description',
+		);
+
+		sort( $expected );
+		sort( $actual );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( $expected, $actual );
 	}
 
-	public function test_get_item_is_not_public_no_permission() {
+	public function test_get_item_shows_public_items_no_permission() {
 		wp_set_current_user( self::$author );
 		$request  = new WP_REST_Request( 'GET', '/wp/v2/settings' );
 		$response = rest_get_server()->dispatch( $request );
-		$this->assertEquals( 403, $response->get_status() );
+		$data     = $response->get_data();
+		$actual   = array_keys( $data );
+
+		$expected = array(
+			'title',
+			'description',
+		);
+
+		sort( $expected );
+		sort( $actual );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( $expected, $actual );
 	}
 
 	public function test_get_items() {
@@ -377,6 +401,21 @@ class WP_Test_REST_Settings_Controller extends WP_Test_REST_Controller_Testcase
 		$this->assertEquals( get_option( 'blogname' ), $data['title'] );
 	}
 
+	public function test_update_item_fails_not_authenticated() {
+		$request = new WP_REST_Request( 'PUT', '/wp/v2/settings' );
+		$request->set_param( 'title', 'The new title!' );
+		$response = rest_get_server()->dispatch( $request );
+		$this->assertEquals( 401, $response->get_status() );
+	}
+
+	public function test_update_item_fails_no_permission() {
+		wp_set_current_user( self::$author );
+		$request = new WP_REST_Request( 'PUT', '/wp/v2/settings' );
+		$request->set_param( 'title', 'The new title!' );
+		$response = rest_get_server()->dispatch( $request );
+		$this->assertEquals( 403, $response->get_status() );
+	}
+
 	public function update_setting_custom_callback( $result, $name, $value, $args ) {
 		if ( 'title' === $name && 'The new title!' === $value ) {
 			// Do not allow changing the title in this case
