Index: src/wp-admin/options.php
===================================================================
--- src/wp-admin/options.php	(revision 43593)
+++ src/wp-admin/options.php	(working copy)
@@ -280,7 +280,18 @@
 				}
 				$value = wp_unslash( $value );
 			}
-			update_option( $option, $value );
+
+			$validity = validate_option( $option, $value );
+
+			if ( is_wp_error( $validity ) ) {
+				foreach ( $validity->errors as $code => $messages ) {
+					foreach ( $messages as $message ) {
+						add_settings_error( $option, $code, $message );
+					}
+				}
+			} else {
+				update_option( $option, $value );
+			}
 		}
 
 		/*
Index: src/wp-includes/class-wp-customize-manager.php
===================================================================
--- src/wp-includes/class-wp-customize-manager.php	(revision 43593)
+++ src/wp-includes/class-wp-customize-manager.php	(working copy)
@@ -2302,8 +2302,18 @@
 				$validity = $setting->validate( $unsanitized_value );
 			}
 			if ( ! is_wp_error( $validity ) ) {
+				$late_validity = new WP_Error();
+
+				// Use the regular option validation if the Customize setting is an option.
+				if ( 'option' === $setting->type && ! $setting->is_multidimensional() ) {
+					$option_validity = validate_option( $setting->id, $unsanitized_value );
+					if ( is_wp_error( $option_validity ) ) {
+						$late_validity = $option_validity;
+					}
+				}
+
 				/** This filter is documented in wp-includes/class-wp-customize-setting.php */
-				$late_validity = apply_filters( "customize_validate_{$setting->id}", new WP_Error(), $unsanitized_value, $setting );
+				$late_validity = apply_filters( "customize_validate_{$setting->id}", $late_validity, $unsanitized_value, $setting );
 				if ( is_wp_error( $late_validity ) && $late_validity->has_errors() ) {
 					$validity = $late_validity;
 				}
Index: src/wp-includes/class-wp-customize-setting.php
===================================================================
--- src/wp-includes/class-wp-customize-setting.php	(revision 43593)
+++ src/wp-includes/class-wp-customize-setting.php	(working copy)
@@ -311,7 +311,7 @@
 		}
 
 		$id_base                 = $this->id_data['base'];
-		$is_multidimensional     = ! empty( $this->id_data['keys'] );
+		$is_multidimensional     = $this->is_multidimensional();
 		$multidimensional_filter = array( $this, '_multidimensional_preview_filter' );
 
 		/*
@@ -579,6 +579,14 @@
 
 		$validity = new WP_Error();
 
+		// Use the regular option validation if the Customize setting is an option.
+		if ( 'option' === $this->type && ! $this->is_multidimensional() ) {
+			$option_validity = validate_option( $this->id, $value );
+			if ( is_wp_error( $option_validity ) ) {
+				$validity = $option_validity;
+			}
+		}
+
 		/**
 		 * Validates a Customize setting value.
 		 *
@@ -635,6 +643,11 @@
 	protected function set_root_value( $value ) {
 		$id_base = $this->id_data['base'];
 		if ( 'option' === $this->type ) {
+			$option_validity = validate_option( $id_base, $value );
+			if ( is_wp_error( $option_validity ) ) {
+				return false;
+			}
+
 			$autoload = true;
 			if ( isset( self::$aggregated_multidimensionals[ $this->type ][ $this->id_data['base'] ]['autoload'] ) ) {
 				$autoload = self::$aggregated_multidimensionals[ $this->type ][ $this->id_data['base'] ]['autoload'];
@@ -827,6 +840,17 @@
 	}
 
 	/**
+	 * Checks whether the setting is part of a multidimensional root.
+	 *
+	 * @since 5.0.0
+	 *
+	 * @return bool True if the setting is multidimensional, false otherwise.
+	 */
+	final public function is_multidimensional() {
+		return ! empty( $this->id_data['keys'] );
+	}
+
+	/**
 	 * Multidimensional helper function.
 	 *
 	 * @since 3.4.0
Index: src/wp-includes/formatting.php
===================================================================
--- src/wp-includes/formatting.php	(revision 43593)
+++ src/wp-includes/formatting.php	(working copy)
@@ -4381,6 +4381,42 @@
 }
 
 /**
+ * Validates an option value based on the nature of the option.
+ *
+ * The {@see 'validate_option_$option'} action should be used to add errors
+ * to the `WP_Error` object passed-through.
+ *
+ * @since 5.0.0
+ *
+ * @param string $option The name of the option.
+ * @param string $value  The unsanitized value.
+ * @return true|WP_Error True if the input was validated, otherwise WP_Error.
+ */
+function validate_option( $option, $value ) {
+	$errors = new WP_Error();
+
+	/**
+	 * Validates an option value.
+	 *
+	 * Plugins should amend the `$errors` object via its `WP_Error::add()` method.
+	 *
+	 * The dynamic portion of the hook name, `$option`, refers to the option name.
+	 *
+	 * @since 5.0.0
+	 *
+	 * @param WP_Error $errors Error object to add validation errors to.
+	 * @param mixed    $value  The option value.
+	 */
+	do_action( "validate_option_{$option}", $errors, $value );
+
+	if ( empty( $errors->errors ) ) {
+		return true;
+	}
+
+	return $errors;
+}
+
+/**
  * Sanitises various option values based on the nature of the option.
  *
  * This is basically a switch statement which will pass $value through a number
Index: src/wp-includes/option.php
===================================================================
--- src/wp-includes/option.php	(revision 43593)
+++ src/wp-includes/option.php	(working copy)
@@ -2075,6 +2075,7 @@
  *
  * @since 2.7.0
  * @since 4.7.0 `$args` can be passed to set flags on the setting, similar to `register_meta()`.
+ * @since 5.0.0 Introduced the `$validate_callback` argument.
  *
  * @global array $new_whitelist_options
  * @global array $wp_registered_settings
@@ -2088,6 +2089,7 @@
  *     @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 $validate_callback A callback that checks validity of the option's value.
  *     @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()`.
@@ -2100,6 +2102,7 @@
 		'type'              => 'string',
 		'group'             => $option_group,
 		'description'       => '',
+		'validate_callback' => null,
 		'sanitize_callback' => null,
 		'show_in_rest'      => false,
 	);
@@ -2155,6 +2158,9 @@
 	}
 
 	$new_whitelist_options[ $option_group ][] = $option_name;
+	if ( ! empty( $args['validate_callback'] ) ) {
+		add_action( "validate_option_{$option_name}", $args['validate_callback'], 10, 2 );
+	}
 	if ( ! empty( $args['sanitize_callback'] ) ) {
 		add_filter( "sanitize_option_{$option_name}", $args['sanitize_callback'] );
 	}
@@ -2225,6 +2231,11 @@
 	}
 
 	if ( isset( $wp_registered_settings[ $option_name ] ) ) {
+		// Remove the validate callback if one was set during registration.
+		if ( ! empty( $wp_registered_settings[ $option_name ]['validate_callback'] ) ) {
+			remove_filter( "validate_option_{$option_name}", $wp_registered_settings[ $option_name ]['validate_callback'], 10 );
+		}
+
 		// Remove the sanitize callback if one was set during registration.
 		if ( ! empty( $wp_registered_settings[ $option_name ]['sanitize_callback'] ) ) {
 			remove_filter( "sanitize_option_{$option_name}", $wp_registered_settings[ $option_name ]['sanitize_callback'] );
Index: src/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php
===================================================================
--- src/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php	(revision 43593)
+++ src/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php	(working copy)
@@ -197,6 +197,15 @@
 
 				delete_option( $args['option_name'] );
 			} else {
+				$validity = validate_option( $args['option_name'], $request[ $name ] );
+				if ( is_wp_error( $validity ) ) {
+					foreach ( $validity->errors as $code => $messages ) {
+						$validity->add_data( array( 'status' => 400 ), $code );
+					}
+
+					return $validity;
+				}
+
 				update_option( $args['option_name'], $request[ $name ] );
 			}
 		}
