diff --git a/core/modules/filter/config/schema/filter.schema.yml b/core/modules/filter/config/schema/filter.schema.yml
index 9a70874d8a10976aa6875763ee65bed2d6978173..f3d13c7bfdc989cceda56f082fe8ade5b13458a3 100644
--- a/core/modules/filter/config/schema/filter.schema.yml
+++ b/core/modules/filter/config/schema/filter.schema.yml
@@ -46,14 +46,11 @@ filter.format.*:
       label: 'Dependencies'
 
 filter_settings.*:
-  type: sequence
+  type: mapping
   label: 'Filter settings'
-  sequence:
-    type: string
-    label: 'Value'
 
 filter_settings.filter_html:
-  type: filter
+  type: mapping
   label: 'Filter HTML'
   mapping:
     allowed_html:
@@ -66,9 +63,8 @@ filter_settings.filter_html:
       type: boolean
       label: 'HTML nofollow'
 
-
 filter_settings.filter_url:
-  type: filter
+  type: mapping
   label: 'Filter URL'
   mapping:
     filter_url_length:
diff --git a/core/modules/filter/filter.post_update.php b/core/modules/filter/filter.post_update.php
index 77699e25339eb981a4aee892b36f54b3a4196429..69509c7ffe629efdc16a2a4f36799945503e3d0f 100644
--- a/core/modules/filter/filter.post_update.php
+++ b/core/modules/filter/filter.post_update.php
@@ -7,6 +7,7 @@
 
 use Drupal\Core\Config\Entity\ConfigEntityUpdater;
 use Drupal\filter\Entity\FilterFormat;
+use Drupal\filter\FilterFormatInterface;
 
 /**
  * Sorts filter format filter configuration.
@@ -19,3 +20,17 @@ function filter_post_update_sort_filters(?array &$sandbox = NULL): void {
     return $sorted_filters !== $filters;
   });
 }
+
+/**
+ * Change filter_settings to type mapping.
+ */
+function filter_post_update_consolidate_filter_config(?array &$sandbox = NULL): void {
+  \Drupal::classResolver(ConfigEntityUpdater::class)->update($sandbox, 'filter_format', function (FilterFormatInterface $format): bool {
+    foreach ($format->get('filters') as $config) {
+      if (empty($config['id']) || empty($config['provider'])) {
+        return TRUE;
+      }
+    }
+    return FALSE;
+  });
+}
diff --git a/core/modules/filter/src/Entity/FilterFormat.php b/core/modules/filter/src/Entity/FilterFormat.php
index b47ed360cda14f1d5a2f5b9afc36584af275d6df..1742bcf1b43b5ebe299742c3a1fcf5b2922235b4 100644
--- a/core/modules/filter/src/Entity/FilterFormat.php
+++ b/core/modules/filter/src/Entity/FilterFormat.php
@@ -208,6 +208,11 @@ public function preSave(EntityStorageInterface $storage) {
       // read and there is a minimal changeset. If the save is not trusted then
       // the configuration will be sorted by StorableConfigBase.
       ksort($this->filters);
+      // Ensure the filter configuration is well-formed.
+      array_walk($this->filters, function (array &$config, string $filter): void {
+        $config['id'] ??= $filter;
+        $config['provider'] ??= $this->filters($filter)->getPluginDefinition()['provider'];
+      });
     }
 
     assert(is_string($this->label()), 'Filter format label is expected to be a string.');
diff --git a/core/modules/filter/src/FilterFormatFormBase.php b/core/modules/filter/src/FilterFormatFormBase.php
index 7ff00b89de3315c6c7a1502977158ce300de45bd..7f5d4e242a6caf54ee7a9f3e695d4f8c141b431b 100644
--- a/core/modules/filter/src/FilterFormatFormBase.php
+++ b/core/modules/filter/src/FilterFormatFormBase.php
@@ -131,6 +131,20 @@ public function form(array $form, FormStateInterface $form_state) {
         '#attributes' => ['class' => ['filter-order-weight']],
       ];
 
+      // Ensure the resulting FilterFormat complies with `type: filter`.
+      // @see core.data_types.schema.yml
+      // @see \Drupal\filter\FilterFormatFormBase::submitForm()
+      $form['filters']['order'][$name]['id'] = [
+        '#type' => 'value',
+        '#value' => $filter->getPluginId(),
+        '#parents' => ['filters', $name, 'id'],
+      ];
+      $form['filters']['order'][$name]['provider'] = [
+        '#type' => 'value',
+        '#value' => $filter->provider,
+        '#parents' => ['filters', $name, 'provider'],
+      ];
+
       // Retrieve the settings form of the filter plugin. The plugin should not be
       // aware of the text format. Therefore, it only receives a set of minimal
       // base properties to allow advanced implementations to work.
diff --git a/core/modules/filter/tests/filter_test/config/schema/filter_test.schema.yml b/core/modules/filter/tests/filter_test/config/schema/filter_test.schema.yml
index f54502c16df80e78e3517bab24f35e09a7260f3b..51dc5e61b4d81f0ab15ba0dbf95f75844b046e6d 100644
--- a/core/modules/filter/tests/filter_test/config/schema/filter_test.schema.yml
+++ b/core/modules/filter/tests/filter_test/config/schema/filter_test.schema.yml
@@ -1,7 +1,7 @@
 # Schema for the configuration files of the Filter test module.
 
 filter_settings.filter_test_restrict_tags_and_attributes:
-  type: filter
+  type: mapping
   label: 'Filter to restrict HTML tags and attributes'
   mapping:
     restrictions:
diff --git a/core/modules/filter/tests/fixtures/update/filter_post_update_consolidate_filter_config-3404431.php b/core/modules/filter/tests/fixtures/update/filter_post_update_consolidate_filter_config-3404431.php
new file mode 100644
index 0000000000000000000000000000000000000000..6b73c76906f854cb4872de9dc2871bcaaf811b67
--- /dev/null
+++ b/core/modules/filter/tests/fixtures/update/filter_post_update_consolidate_filter_config-3404431.php
@@ -0,0 +1,32 @@
+<?php
+
+/**
+ * @file
+ * Fixture file to test filter_post_update_consolidate_filter_config().
+ *
+ * @see https://www.drupal.org/project/drupal/issues/3404431
+ * @see \Drupal\Tests\filter\Functional\FilterFormatConsolidateFilterConfigUpdateTest
+ * @see filter_post_update_consolidate_filter_config()
+ */
+
+use Drupal\Core\Database\Database;
+
+$db = Database::getConnection();
+
+$format = unserialize($db->select('config')
+  ->fields('config', ['data'])
+  ->condition('collection', '')
+  ->condition('name', 'filter.format.plain_text')
+  ->execute()
+  ->fetchField());
+
+unset($format['filters']['filter_autop']['id']);
+unset($format['filters']['filter_html_escape']['provider']);
+unset($format['filters']['filter_url']['id']);
+unset($format['filters']['filter_url']['provider']);
+
+$db->update('config')
+  ->fields(['data' => serialize($format)])
+  ->condition('collection', '')
+  ->condition('name', 'filter.format.plain_text')
+  ->execute();
diff --git a/core/modules/filter/tests/src/Functional/FilterFormatConsolidateFilterConfigUpdateTest.php b/core/modules/filter/tests/src/Functional/FilterFormatConsolidateFilterConfigUpdateTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..23e279aa029e8ef9bfe9a6086516f08c5c9827bb
--- /dev/null
+++ b/core/modules/filter/tests/src/Functional/FilterFormatConsolidateFilterConfigUpdateTest.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\Tests\filter\Functional;
+
+use Drupal\FunctionalTests\Update\UpdatePathTestBase;
+
+/**
+ * Tests the upgrade path for filter formats.
+ *
+ * @see filter_post_update_consolidate_filter_config()
+ *
+ * @group Update
+ * @group legacy
+ */
+class FilterFormatConsolidateFilterConfigUpdateTest extends UpdatePathTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setDatabaseDumpFiles(): void {
+    $this->databaseDumpFiles = [
+      __DIR__ . '/../../../../system/tests/fixtures/update/drupal-9.4.0.bare.standard.php.gz',
+      __DIR__ . '/../../fixtures/update/filter_post_update_consolidate_filter_config-3404431.php',
+    ];
+  }
+
+  /**
+   * @covers \filter_post_update_consolidate_filter_config
+   */
+  public function testConsolidateFilterConfig() {
+    $format = $this->config('filter.format.plain_text');
+    $this->assertArrayNotHasKey('id', $format->get('filters.filter_autop'));
+    $this->assertSame('filter', $format->get('filters.filter_autop.provider'));
+    $this->assertSame('filter_html_escape', $format->get('filters.filter_html_escape.id'));
+    $this->assertArrayNotHasKey('provider', $format->get('filters.filter_html_escape'));
+    $this->assertArrayNotHasKey('id', $format->get('filters.filter_url'));
+    $this->assertArrayNotHasKey('provider', $format->get('filters.filter_url'));
+
+    $this->runUpdates();
+
+    $format = $this->config('filter.format.plain_text');
+    $this->assertSame('filter_autop', $format->get('filters.filter_autop.id'));
+    $this->assertSame('filter', $format->get('filters.filter_autop.provider'));
+    $this->assertSame('filter_html_escape', $format->get('filters.filter_html_escape.id'));
+    $this->assertSame('filter', $format->get('filters.filter_html_escape.provider'));
+    $this->assertSame('filter_url', $format->get('filters.filter_url.id'));
+    $this->assertSame('filter', $format->get('filters.filter_url.provider'));
+  }
+
+}
diff --git a/core/modules/media/config/schema/media.schema.yml b/core/modules/media/config/schema/media.schema.yml
index 9c804c620e1ee6e42ffd70070b7be815a974283e..1e1bfb5ad3fd442665d05980514ae0949a6a7263 100644
--- a/core/modules/media/config/schema/media.schema.yml
+++ b/core/modules/media/config/schema/media.schema.yml
@@ -119,7 +119,7 @@ media.source.field_aware:
       label: 'Source field'
 
 filter_settings.media_embed:
-  type: filter
+  type: mapping
   label: 'Media Embed'
   mapping:
     default_view_mode: