diff --git src/wp-includes/rest-api.php src/wp-includes/rest-api.php
index 4b49ebfdcb..68f13c2ac1 100644
--- src/wp-includes/rest-api.php
+++ src/wp-includes/rest-api.php
@@ -696,6 +696,24 @@ function rest_send_allow_header( $response, $server, $request ) {
 	return $response;
 }
 
+/**
+ * Recursively computes the intersection of arrays using keys for comparison.
+ *
+ * @param  array $array1 The array with master keys to check.
+ * @param  array $array2 An array to compare keys against.
+ *
+ * @return array An associative array containing all the entries of array1 which have keys that are present in all arguments.
+ */
+function _rest_array_intersect_key_recursive( $array1, $array2 ) {
+	$array1 = array_intersect_key( $array1, $array2 );
+	foreach ( $array1 as $key => $value ) {
+		if ( is_array( $value ) && is_array( $array2[ $key ] ) ) {
+			$array1[ $key ] = _rest_array_intersect_key_recursive( $value, $array2[ $key ] );
+		}
+	}
+	return $array1;
+}
+
 /**
  * Filter the API response to include only a white-listed set of response object fields.
  *
@@ -723,15 +741,27 @@ function rest_filter_response_fields( $response, $server, $request ) {
 	// Trim off outside whitespace from the comma delimited list.
 	$fields = array_map( 'trim', $fields );
 
-	$fields_as_keyed = array_combine( $fields, array_fill( 0, count( $fields ), true ) );
+	// Create nested array of accepted field hierarchy.
+	$fields_as_keyed = array();
+	foreach ( $fields as $field ) {
+		$parts = explode( '.', $field );
+		$ref   = &$fields_as_keyed;
+		while ( count( $parts ) > 1 ) {
+			$next         = array_shift( $parts );
+			$ref[ $next ] = array();
+			$ref          = &$ref[ $next ];
+		}
+		$last         = array_shift( $parts );
+		$ref[ $last ] = true;
+	}
 
 	if ( wp_is_numeric_array( $data ) ) {
 		$new_data = array();
 		foreach ( $data as $item ) {
-			$new_data[] = array_intersect_key( $item, $fields_as_keyed );
+			$new_data[] = _rest_array_intersect_key_recursive( $item, $fields_as_keyed );
 		}
 	} else {
-		$new_data = array_intersect_key( $data, $fields_as_keyed );
+		$new_data = _rest_array_intersect_key_recursive( $data, $fields_as_keyed );
 	}
 
 	$response->set_data( $new_data );
@@ -739,6 +769,41 @@ function rest_filter_response_fields( $response, $server, $request ) {
 	return $response;
 }
 
+/**
+ * Given an array of fields to include in a response, some of which may be
+ * `nested.fields`, determine whether the provided field should be included
+ * in the response body.
+ *
+ * If a parent field is passed in, the presence of any nested field within
+ * that parent will cause the method to return `true`. For example "title"
+ * will return true if any of `title`, `title.raw` or `title.rendered` is
+ * provided.
+ *
+ * @since 5.3.0
+ *
+ * @param string $field  A field to test for inclusion in the response body.
+ * @param array  $fields An array of string fields supported by the endpoint.
+ * @return bool Whether to include the field or not.
+ */
+function rest_is_field_included( $field, $fields ) {
+	if ( in_array( $field, $fields, true ) ) {
+		return true;
+	}
+	foreach ( $fields as $accepted_field ) {
+		// Check to see if $field is the parent of any item in $fields.
+		// A field "parent" should be accepted if "parent.child" is accepted.
+		if ( strpos( $accepted_field, "$field." ) === 0 ) {
+			return true;
+		}
+		// Conversely, if "parent" is accepted, all "parent.child" fields should
+		// also be accepted.
+		if ( strpos( $field, "$accepted_field." ) === 0 ) {
+			return true;
+		}
+	}
+	return false;
+}
+
 /**
  * Adds the REST API URL to the WP RSD endpoint.
  *
diff --git src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php
index 7adc5ed616..1bc38124e9 100644
--- src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php
+++ src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php
@@ -562,7 +562,25 @@ abstract class WP_REST_Controller {
 		if ( in_array( 'id', $fields, true ) ) {
 			$requested_fields[] = 'id';
 		}
-		return array_intersect( $fields, $requested_fields );
+		// Return the list of all requested fields which appear in the schema.
+		return array_reduce(
+			$requested_fields,
+			function( $response_fields, $field ) use ( $fields ) {
+				if ( in_array( $field, $fields, true ) ) {
+					$response_fields[] = $field;
+					return $response_fields;
+				}
+				// Check for nested fields if $field is not a direct match.
+				$nested_fields = explode( '.', $field );
+				// A nested field is included so long as its top-level property is
+				// present in the schema.
+				if ( in_array( $nested_fields[0], $fields, true ) ) {
+					$response_fields[] = $field;
+				}
+				return $response_fields;
+			},
+			array()
+		);
 	}
 
 	/**
diff --git src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php
index e53ff4b910..883e9b31fe 100644
--- src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php
+++ src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php
@@ -1439,15 +1439,15 @@ class WP_REST_Posts_Controller extends WP_REST_Controller {
 		// Base fields for every post.
 		$data = array();
 
-		if ( in_array( 'id', $fields, true ) ) {
+		if ( rest_is_field_included( 'id', $fields ) ) {
 			$data['id'] = $post->ID;
 		}
 
-		if ( in_array( 'date', $fields, true ) ) {
+		if ( rest_is_field_included( 'date', $fields ) ) {
 			$data['date'] = $this->prepare_date_response( $post->post_date_gmt, $post->post_date );
 		}
 
-		if ( in_array( 'date_gmt', $fields, true ) ) {
+		if ( rest_is_field_included( 'date_gmt', $fields ) ) {
 			// For drafts, `post_date_gmt` may not be set, indicating that the
 			// date of the draft should be updated each time it is saved (see
 			// #38883).  In this case, shim the value based on the `post_date`
@@ -1460,7 +1460,7 @@ class WP_REST_Posts_Controller extends WP_REST_Controller {
 			$data['date_gmt'] = $this->prepare_date_response( $post_date_gmt );
 		}
 
-		if ( in_array( 'guid', $fields, true ) ) {
+		if ( rest_is_field_included( 'guid', $fields ) ) {
 			$data['guid'] = array(
 				/** This filter is documented in wp-includes/post-template.php */
 				'rendered' => apply_filters( 'get_the_guid', $post->guid, $post->ID ),
@@ -1468,11 +1468,11 @@ class WP_REST_Posts_Controller extends WP_REST_Controller {
 			);
 		}
 
-		if ( in_array( 'modified', $fields, true ) ) {
+		if ( rest_is_field_included( 'modified', $fields ) ) {
 			$data['modified'] = $this->prepare_date_response( $post->post_modified_gmt, $post->post_modified );
 		}
 
-		if ( in_array( 'modified_gmt', $fields, true ) ) {
+		if ( rest_is_field_included( 'modified_gmt', $fields ) ) {
 			// For drafts, `post_modified_gmt` may not be set (see
 			// `post_date_gmt` comments above).  In this case, shim the value
 			// based on the `post_modified` field with the site's timezone
@@ -1485,33 +1485,36 @@ class WP_REST_Posts_Controller extends WP_REST_Controller {
 			$data['modified_gmt'] = $this->prepare_date_response( $post_modified_gmt );
 		}
 
-		if ( in_array( 'password', $fields, true ) ) {
+		if ( rest_is_field_included( 'password', $fields ) ) {
 			$data['password'] = $post->post_password;
 		}
 
-		if ( in_array( 'slug', $fields, true ) ) {
+		if ( rest_is_field_included( 'slug', $fields ) ) {
 			$data['slug'] = $post->post_name;
 		}
 
-		if ( in_array( 'status', $fields, true ) ) {
+		if ( rest_is_field_included( 'status', $fields ) ) {
 			$data['status'] = $post->post_status;
 		}
 
-		if ( in_array( 'type', $fields, true ) ) {
+		if ( rest_is_field_included( 'type', $fields ) ) {
 			$data['type'] = $post->post_type;
 		}
 
-		if ( in_array( 'link', $fields, true ) ) {
+		if ( rest_is_field_included( 'link', $fields ) ) {
 			$data['link'] = get_permalink( $post->ID );
 		}
 
-		if ( in_array( 'title', $fields, true ) ) {
+		if ( rest_is_field_included( 'title', $fields ) ) {
+			$data['title'] = array();
+		}
+		if ( rest_is_field_included( 'title.raw', $fields ) ) {
+			$data['title']['raw'] = $post->post_title;
+		}
+		if ( rest_is_field_included( 'title.rendered', $fields ) ) {
 			add_filter( 'protected_title_format', array( $this, 'protected_title_format' ) );
 
-			$data['title'] = array(
-				'raw'      => $post->post_title,
-				'rendered' => get_the_title( $post->ID ),
-			);
+			$data['title']['rendered'] = get_the_title( $post->ID );
 
 			remove_filter( 'protected_title_format', array( $this, 'protected_title_format' ) );
 		}
@@ -1525,17 +1528,24 @@ class WP_REST_Posts_Controller extends WP_REST_Controller {
 			$has_password_filter = true;
 		}
 
-		if ( in_array( 'content', $fields, true ) ) {
-			$data['content'] = array(
-				'raw'           => $post->post_content,
-				/** This filter is documented in wp-includes/post-template.php */
-				'rendered'      => post_password_required( $post ) ? '' : apply_filters( 'the_content', $post->post_content ),
-				'protected'     => (bool) $post->post_password,
-				'block_version' => block_version( $post->post_content ),
-			);
+		if ( rest_is_field_included( 'content', $fields ) ) {
+			$data['content'] = array();
+		}
+		if ( rest_is_field_included( 'content.raw', $fields ) ) {
+			$data['content']['raw'] = $post->post_content;
+		}
+		if ( rest_is_field_included( 'content.rendered', $fields ) ) {
+			/** This filter is documented in wp-includes/post-template.php */
+			$data['content']['rendered'] = post_password_required( $post ) ? '' : apply_filters( 'the_content', $post->post_content );
+		}
+		if ( rest_is_field_included( 'content.protected', $fields ) ) {
+			$data['content']['protected'] = (bool) $post->post_password;
+		}
+		if ( rest_is_field_included( 'content.block_version', $fields ) ) {
+			$data['content']['block_version'] = block_version( $post->post_content );
 		}
 
-		if ( in_array( 'excerpt', $fields, true ) ) {
+		if ( rest_is_field_included( 'excerpt', $fields ) ) {
 			/** This filter is documented in wp-includes/post-template.php */
 			$excerpt         = apply_filters( 'the_excerpt', apply_filters( 'get_the_excerpt', $post->post_excerpt, $post ) );
 			$data['excerpt'] = array(
@@ -1550,35 +1560,35 @@ class WP_REST_Posts_Controller extends WP_REST_Controller {
 			remove_filter( 'post_password_required', '__return_false' );
 		}
 
-		if ( in_array( 'author', $fields, true ) ) {
+		if ( rest_is_field_included( 'author', $fields ) ) {
 			$data['author'] = (int) $post->post_author;
 		}
 
-		if ( in_array( 'featured_media', $fields, true ) ) {
+		if ( rest_is_field_included( 'featured_media', $fields ) ) {
 			$data['featured_media'] = (int) get_post_thumbnail_id( $post->ID );
 		}
 
-		if ( in_array( 'parent', $fields, true ) ) {
+		if ( rest_is_field_included( 'parent', $fields ) ) {
 			$data['parent'] = (int) $post->post_parent;
 		}
 
-		if ( in_array( 'menu_order', $fields, true ) ) {
+		if ( rest_is_field_included( 'menu_order', $fields ) ) {
 			$data['menu_order'] = (int) $post->menu_order;
 		}
 
-		if ( in_array( 'comment_status', $fields, true ) ) {
+		if ( rest_is_field_included( 'comment_status', $fields ) ) {
 			$data['comment_status'] = $post->comment_status;
 		}
 
-		if ( in_array( 'ping_status', $fields, true ) ) {
+		if ( rest_is_field_included( 'ping_status', $fields ) ) {
 			$data['ping_status'] = $post->ping_status;
 		}
 
-		if ( in_array( 'sticky', $fields, true ) ) {
+		if ( rest_is_field_included( 'sticky', $fields ) ) {
 			$data['sticky'] = is_sticky( $post->ID );
 		}
 
-		if ( in_array( 'template', $fields, true ) ) {
+		if ( rest_is_field_included( 'template', $fields ) ) {
 			$template = get_page_template_slug( $post->ID );
 			if ( $template ) {
 				$data['template'] = $template;
@@ -1587,7 +1597,7 @@ class WP_REST_Posts_Controller extends WP_REST_Controller {
 			}
 		}
 
-		if ( in_array( 'format', $fields, true ) ) {
+		if ( rest_is_field_included( 'format', $fields ) ) {
 			$data['format'] = get_post_format( $post->ID );
 
 			// Fill in blank post format.
@@ -1596,7 +1606,7 @@ class WP_REST_Posts_Controller extends WP_REST_Controller {
 			}
 		}
 
-		if ( in_array( 'meta', $fields, true ) ) {
+		if ( rest_is_field_included( 'meta', $fields ) ) {
 			$data['meta'] = $this->meta->get_value( $post->ID, $request );
 		}
 
@@ -1605,7 +1615,7 @@ class WP_REST_Posts_Controller extends WP_REST_Controller {
 		foreach ( $taxonomies as $taxonomy ) {
 			$base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name;
 
-			if ( in_array( $base, $fields, true ) ) {
+			if ( rest_is_field_included( $base, $fields ) ) {
 				$terms         = get_the_terms( $post, $taxonomy->name );
 				$data[ $base ] = $terms ? array_values( wp_list_pluck( $terms, 'term_id' ) ) : array();
 			}
@@ -1613,8 +1623,8 @@ class WP_REST_Posts_Controller extends WP_REST_Controller {
 
 		$post_type_obj = get_post_type_object( $post->post_type );
 		if ( is_post_type_viewable( $post_type_obj ) && $post_type_obj->public ) {
-			$permalink_template_requested = in_array( 'permalink_template', $fields, true );
-			$generated_slug_requested     = in_array( 'generated_slug', $fields, true );
+			$permalink_template_requested = rest_is_field_included( 'permalink_template', $fields );
+			$generated_slug_requested     = rest_is_field_included( 'generated_slug', $fields );
 
 			if ( $permalink_template_requested || $generated_slug_requested ) {
 				if ( ! function_exists( 'get_sample_permalink' ) ) {
diff --git tests/phpunit/tests/rest-api.php tests/phpunit/tests/rest-api.php
index c81efd67b6..512aa61b6d 100644
--- tests/phpunit/tests/rest-api.php
+++ tests/phpunit/tests/rest-api.php
@@ -519,6 +519,71 @@ class Tests_REST_API extends WP_UnitTestCase {
 		);
 	}
 
+	/**
+	 * Ensure that nested fields may be whitelisted with request['_fields'].
+	 *
+	 * @ticket 42094
+	 */
+	public function test_rest_filter_response_fields_nested_field_filter() {
+		$response = new WP_REST_Response();
+
+		$response->set_data(
+			array(
+				'a' => 0,
+				'b' => array(
+					'1' => 1,
+					'2' => 2,
+				),
+				'c' => 3,
+				'd' => array(
+					'4' => 4,
+					'5' => 5,
+				),
+			)
+		);
+		$request = array(
+			'_fields' => 'b.1,c,d.5',
+		);
+
+		$response = rest_filter_response_fields( $response, null, $request );
+		$this->assertEquals(
+			array(
+				'b' => array(
+					'1' => 1,
+				),
+				'c' => 3,
+				'd' => array(
+					'5' => 5,
+				),
+			),
+			$response->get_data()
+		);
+	}
+
+	/**
+	 * @ticket 42094
+	 */
+	public function test_rest_is_field_included() {
+		$fields = array(
+			'id',
+			'title',
+			'content.raw',
+			'custom.property',
+		);
+
+		$this->assertTrue( rest_is_field_included( 'id', $fields ) );
+		$this->assertTrue( rest_is_field_included( 'title', $fields ) );
+		$this->assertTrue( rest_is_field_included( 'title.raw', $fields ) );
+		$this->assertTrue( rest_is_field_included( 'title.rendered', $fields ) );
+		$this->assertTrue( rest_is_field_included( 'content', $fields ) );
+		$this->assertTrue( rest_is_field_included( 'content.raw', $fields ) );
+		$this->assertTrue( rest_is_field_included( 'custom.property', $fields ) );
+		$this->assertFalse( rest_is_field_included( 'content.rendered', $fields ) );
+		$this->assertFalse( rest_is_field_included( 'type', $fields ) );
+		$this->assertFalse( rest_is_field_included( 'meta', $fields ) );
+		$this->assertFalse( rest_is_field_included( 'meta.value', $fields ) );
+	}
+
 	/**
 	 * The get_rest_url function should return a URL consistently terminated with a "/",
 	 * whether the blog is configured with pretty permalink support or not.
diff --git tests/phpunit/tests/rest-api/rest-controller.php tests/phpunit/tests/rest-api/rest-controller.php
index f130abc62d..91e1d0c5b7 100644
--- tests/phpunit/tests/rest-api/rest-controller.php
+++ tests/phpunit/tests/rest-api/rest-controller.php
@@ -232,7 +232,7 @@ class WP_Test_REST_Controller extends WP_Test_REST_TestCase {
 	public function data_get_fields_for_response() {
 		return array(
 			array(
-				'somestring,someinteger',
+				'somestring,someinteger,someinvalidkey',
 				array(
 					'somestring',
 					'someinteger',
diff --git tests/phpunit/tests/rest-api/rest-posts-controller.php tests/phpunit/tests/rest-api/rest-posts-controller.php
index ba174c448e..95e8ee11c2 100644
--- tests/phpunit/tests/rest-api/rest-posts-controller.php
+++ tests/phpunit/tests/rest-api/rest-posts-controller.php
@@ -1689,6 +1689,76 @@ class WP_Test_REST_Posts_Controller extends WP_Test_REST_Post_Type_Controller_Te
 		);
 	}
 
+	/**
+	 * @ticket 42094
+	 */
+	public function test_prepare_item_filters_content_when_needed() {
+		$filter_count   = 0;
+		$filter_content = function() use ( &$filter_count ) {
+			$filter_count++;
+			return '<p>Filtered content.</p>';
+		};
+		add_filter( 'the_content', $filter_content );
+
+		wp_set_current_user( self::$editor_id );
+		$endpoint = new WP_REST_Posts_Controller( 'post' );
+		$request  = new WP_REST_REQUEST( 'GET', sprintf( '/wp/v2/posts/%d', self::$post_id ) );
+
+		$request->set_param( 'context', 'edit' );
+		$request->set_param( '_fields', 'content.rendered' );
+
+		$post     = get_post( self::$post_id );
+		$response = $endpoint->prepare_item_for_response( $post, $request );
+
+		remove_filter( 'the_content', $filter_content );
+
+		$this->assertEquals(
+			array(
+				'id'      => self::$post_id,
+				'content' => array(
+					'rendered' => '<p>Filtered content.</p>',
+				),
+			),
+			$response->get_data()
+		);
+		$this->assertSame( 1, $filter_count );
+	}
+
+	/**
+	 * @ticket 42094
+	 */
+	public function test_prepare_item_skips_content_filter_if_not_needed() {
+		$filter_count   = 0;
+		$filter_content = function() use ( &$filter_count ) {
+			$filter_count++;
+			return '<p>Filtered content.</p>';
+		};
+		add_filter( 'the_content', $filter_content );
+
+		wp_set_current_user( self::$editor_id );
+		$endpoint = new WP_REST_Posts_Controller( 'post' );
+		$request  = new WP_REST_REQUEST( 'GET', sprintf( '/wp/v2/posts/%d', self::$post_id ) );
+
+		$request->set_param( 'context', 'edit' );
+		$request->set_param( '_fields', 'content.raw' );
+
+		$post     = get_post( self::$post_id );
+		$response = $endpoint->prepare_item_for_response( $post, $request );
+
+		remove_filter( 'the_content', $filter_content );
+
+		$this->assertEquals(
+			array(
+				'id'      => $post->ID,
+				'content' => array(
+					'raw' => $post->post_content,
+				),
+			),
+			$response->get_data()
+		);
+		$this->assertSame( 0, $filter_count );
+	}
+
 	public function test_create_item() {
 		wp_set_current_user( self::$editor_id );
 
