diff --git a/core/modules/user/src/Form/UserPermissionsForm.php b/core/modules/user/src/Form/UserPermissionsForm.php
index 49b8511e8ff568f902b06ceb4725b6a8c70240b5..d1e9b9932657d17ddd33278ea7d4ecc29edfcb49 100644
--- a/core/modules/user/src/Form/UserPermissionsForm.php
+++ b/core/modules/user/src/Form/UserPermissionsForm.php
@@ -142,6 +142,27 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       '#type' => 'system_compact_link',
     ];
 
+    $form['filters'] = [
+      '#type' => 'container',
+      '#attributes' => [
+        'class' => ['table-filter', 'js-show'],
+      ],
+    ];
+
+    $form['filters']['text'] = [
+      '#type' => 'search',
+      '#title' => $this->t('Filter permissions'),
+      '#title_display' => 'invisible',
+      '#size' => 30,
+      '#placeholder' => $this->t('Filter by permission name'),
+      '#description' => $this->t('Enter permission name'),
+      '#attributes' => [
+        'class' => ['table-filter-text'],
+        'data-table' => '#permissions',
+        'autocomplete' => 'off',
+      ],
+    ];
+
     $form['permissions'] = [
       '#type' => 'table',
       '#header' => [$this->t('Permission')],
@@ -177,7 +198,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
         ];
         $form['permissions'][$perm]['description'] = [
           '#type' => 'inline_template',
-          '#template' => '<div class="permission"><span class="title">{{ title }}</span>{% if description or warning %}<div class="description">{% if warning %}<em class="permission-warning">{{ warning }}</em> {% endif %}{{ description }}</div>{% endif %}</div>',
+          '#template' => '<div class="permission"><span class="title table-filter-text-source">{{ title }}</span>{% if description or warning %}<div class="description">{% if warning %}<em class="permission-warning">{{ warning }}</em> {% endif %}{{ description }}</div>{% endif %}</div>',
           '#context' => [
             'title' => $perm_item['title'],
           ],
diff --git a/core/modules/user/tests/src/FunctionalJavascript/PermissionFilterTest.php b/core/modules/user/tests/src/FunctionalJavascript/PermissionFilterTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b98e2448eb9fd19d55745a877cd9e389d9b29b5e
--- /dev/null
+++ b/core/modules/user/tests/src/FunctionalJavascript/PermissionFilterTest.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Drupal\Tests\user\FunctionalJavascript;
+
+use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
+
+/**
+ * Tests the JavaScript functionality of the permission filter.
+ *
+ * @group user
+ */
+class PermissionFilterTest extends WebDriverTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['user', 'system'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $admin_user = $this->drupalCreateUser([
+      'administer permissions',
+    ]);
+    $this->drupalLogin($admin_user);
+  }
+
+  /**
+   * Tests that filter results announcement has correct pluralization.
+   */
+  public function testPermissionFilter() {
+    // Find the permission filter field.
+    $this->drupalGet('admin/people/permissions');
+    $assertSession = $this->assertSession();
+    $session = $this->getSession();
+    $page = $session->getPage();
+
+    $filter = $page->findField('edit-text');
+
+    // Get all permission rows, for assertions later.
+    $permission_rows = $page->findAll('css', 'tbody tr td .permission');
+
+    // Administer filter reduces the number of visible rows.
+    $filter->setValue('Administer');
+    $session->wait(1000, "jQuery('tr[data-drupal-selector=\"edit-permissions-access-content\"]').length == 0");
+    $visible_rows = $this->filterVisibleElements($permission_rows);
+    // Test Drupal.announce() message when multiple matches are expected.
+    $expected_message = count($visible_rows) . ' permissions are available in the modified list.';
+    $assertSession->elementTextContains('css', '#drupal-live-announce', $expected_message);
+    self::assertGreaterThan(count($visible_rows), count($permission_rows));
+    self::assertGreaterThan(1, count($visible_rows));
+
+    // Test Drupal.announce() message when one match is expected.
+    // Using a very specific permission name, we expect only one row.
+    $filter->setValue('Administer site configuration');
+    $session->wait(1000, "jQuery('tr[data-drupal-selector=\"edit-permissions-access-content\"]').length == 0");
+    $visible_rows = $this->filterVisibleElements($permission_rows);
+    self::assertEquals(1, count($visible_rows));
+    $expected_message = '1 permission is available in the modified list.';
+    $assertSession->elementTextContains('css', '#drupal-live-announce', $expected_message);
+
+    // Test Drupal.announce() message when no matches are expected.
+    $filter->setValue('Pan-Galactic Gargle Blaster');
+    $session->wait(1000, "jQuery('tr[data-drupal-selector=\"edit-permissions-access-content\"]').length == 0");
+    $visible_rows = $this->filterVisibleElements($permission_rows);
+    self::assertEquals(0, count($visible_rows));
+
+    $expected_message = '0 permissions are available in the modified list.';
+    $assertSession->elementTextContains('css', '#drupal-live-announce', $expected_message);
+  }
+
+  /**
+   * Removes any non-visible elements from the passed array.
+   *
+   * @param \Behat\Mink\Element\NodeElement[] $elements
+   *   An array of node elements.
+   *
+   * @return \Behat\Mink\Element\NodeElement[]
+   *   An array of node elements.
+   */
+  protected function filterVisibleElements(array $elements): array {
+    $elements = array_filter($elements, function ($element) {
+      return $element->isVisible();
+    });
+    return $elements;
+  }
+
+}
diff --git a/core/modules/user/user.libraries.yml b/core/modules/user/user.libraries.yml
index 2832a9213463d2c5b8ce99466abfc1f2ccc75ec3..5b24ea8fceff00b03cbaa525cbbbab314495a2ca 100644
--- a/core/modules/user/user.libraries.yml
+++ b/core/modules/user/user.libraries.yml
@@ -28,6 +28,8 @@ drupal.user.permissions:
     - core/drupalSettings
     - user/drupal.user.admin
     - core/drupal.checkbox
+    - core/drupal.debounce
+    - core/drupal.announce
 
 drupal.user.icons:
   version: VERSION
diff --git a/core/modules/user/user.permissions.js b/core/modules/user/user.permissions.js
index ae22cbb5e3d927d0f75b751c1c5badc571805450..19a56ae1dce0b905a7290266abd88444ad63cbd8 100644
--- a/core/modules/user/user.permissions.js
+++ b/core/modules/user/user.permissions.js
@@ -3,7 +3,7 @@
  * User permission page behaviors.
  */
 
-(function ($, Drupal) {
+(function ($, Drupal, debounce) {
   /**
    * Shows checked and disabled checkboxes for inherited permissions.
    *
@@ -89,4 +89,116 @@
       });
     },
   };
-})(jQuery, Drupal);
+
+  /**
+   * Filters the permission list table by a text input search string.
+   *
+   * Text search input: input.table-filter-text
+   * Target table:      input.table-filter-text[data-table]
+   * Source text:       .table-filter-text-source
+   *
+   * @type {Drupal~behavior}
+   */
+  Drupal.behaviors.tableFilterByText = {
+    attach(context, settings) {
+      const [input] = once('table-filter-text', 'input.table-filter-text');
+      if (!input) {
+        return;
+      }
+      const tableSelector = input.getAttribute('data-table');
+      const $table = $(tableSelector);
+      const $rows = $table.find('tbody tr');
+
+      function hideEmptyPermissionHeader(index, row) {
+        const tdsWithModuleClass = row.querySelectorAll('td.module');
+        // Function to check if an element is visible (`display: block`).
+        function isVisible(element) {
+          return getComputedStyle(element).display !== 'none';
+        }
+        if (tdsWithModuleClass.length > 0) {
+          // Find the next visible sibling `<tr>`.
+          let nextVisibleSibling = row.nextElementSibling;
+          while (nextVisibleSibling && !isVisible(nextVisibleSibling)) {
+            nextVisibleSibling = nextVisibleSibling.nextElementSibling;
+          }
+
+          // Check if the next visible sibling has the "module" class in any of
+          // its `<td>` elements.
+          let nextVisibleSiblingHasModuleClass = false;
+          if (nextVisibleSibling) {
+            const nextSiblingTdsWithModuleClass =
+              nextVisibleSibling.querySelectorAll('td.module');
+            nextVisibleSiblingHasModuleClass =
+              nextSiblingTdsWithModuleClass.length > 0;
+          }
+
+          // Check if this is the last visible row with class "module".
+          const isLastVisibleModuleRow =
+            !nextVisibleSibling || !isVisible(nextVisibleSibling);
+
+          // Hide the current row with class "module" if it meets the
+          // conditions.
+          $(row).toggle(
+            !nextVisibleSiblingHasModuleClass && !isLastVisibleModuleRow,
+          );
+        }
+      }
+
+      function filterPermissionList(e) {
+        const query = e.target.value;
+        if (query.length === 0) {
+          // Reset table when the textbox is cleared.
+          $rows.show();
+        }
+        // Case insensitive expression to find query at the beginning of a word.
+        const re = new RegExp(`\\b${query}`, 'i');
+
+        function showPermissionRow(index, row) {
+          const sources = row.querySelectorAll('.table-filter-text-source');
+          if (sources.length > 0) {
+            const textMatch = sources[0].textContent.search(re) !== -1;
+            $(row).closest('tr').toggle(textMatch);
+          }
+        }
+        // Search over all rows.
+        $rows.show();
+
+        // Filter if the length of the query is at least 2 characters.
+        if (query.length >= 2) {
+          $rows.each(showPermissionRow);
+
+          // Hide the empty header if they don't have any visible rows.
+          const visibleRows = $table.find('tbody tr:visible');
+          visibleRows.each(hideEmptyPermissionHeader);
+          const rowsWithoutEmptyModuleName = $table.find('tbody tr:visible');
+          // Find elements with class "permission" within visible rows.
+          const tdsWithModuleOrPermissionClass =
+            rowsWithoutEmptyModuleName.find('.permission');
+
+          Drupal.announce(
+            Drupal.formatPlural(
+              tdsWithModuleOrPermissionClass.length,
+              '1 permission is available in the modified list.',
+              '@count permissions are available in the modified list.',
+            ),
+          );
+        }
+      }
+
+      function preventEnterKey(event) {
+        if (event.which === 13) {
+          event.preventDefault();
+          event.stopPropagation();
+        }
+      }
+
+      if ($table.length) {
+        $(input).on({
+          keyup: debounce(filterPermissionList, 200),
+          click: debounce(filterPermissionList, 200),
+          keydown: preventEnterKey,
+        });
+      }
+    },
+  };
+})(jQuery, Drupal, Drupal.debounce);
diff --git a/core/themes/claro/claro.theme b/core/themes/claro/claro.theme
index d174b632561c9c54daf725782d607a9d75aa7a78..0e53735e32ee3b1ef2f3c5ca2a7c5bb97e3fc814 100644
--- a/core/themes/claro/claro.theme
+++ b/core/themes/claro/claro.theme
@@ -1770,3 +1770,16 @@ function claro_system_module_invoked_theme_registry_alter(array &$theme_registry
     }
   }
 }
+
+/**
+ * Implements hook_form_FORM_ID_alter() for the user_admin_permissions form.
+ */
+function claro_form_user_admin_permissions_alter(&$form, FormStateInterface $form_state) {
+  if (isset($form['filters'])) {
+    $form['filters']['#attributes']['class'][] = 'permissions-table-filter';
+    if (isset($form['filters']['text'])) {
+      unset($form['filters']['text']['#title_display']);
+      $form['filters']['text']['#title'] = t('Filter');
+    }
+  }
+}
diff --git a/core/themes/claro/css/components/system-admin--modules.css b/core/themes/claro/css/components/system-admin--modules.css
index 29385332e1195bd71b9b0bf8a7a42363850af814..55d91a507ede4a9c051bd98024ac4b1445a747ca 100644
--- a/core/themes/claro/css/components/system-admin--modules.css
+++ b/core/themes/claro/css/components/system-admin--modules.css
@@ -15,7 +15,8 @@
   --module-table-cell-padding-horizontal: calc(var(--space-m) - (var(--input-border-size) * 2));
 }
 
-.modules-table-filter {
+.modules-table-filter,
+.permissions-table-filter {
   padding: 0.25rem var(--space-l);
   border: 1px solid var(--color-gray-200);
   border-radius: 2px 2px 0 0;
@@ -24,11 +25,13 @@
 
 /* Visually hide the module filter input description. */
 
-.modules-table-filter .form-item__description {
+.modules-table-filter .form-item__description,
+.permissions-table-filter .form-item__description {
   position: absolute !important;
 }
 
-.modules-table-filter .form-item__description {
+.modules-table-filter .form-item__description,
+.permissions-table-filter .form-item__description {
   overflow: hidden;
   clip: rect(1px, 1px, 1px, 1px);
   width: 1px;
diff --git a/core/themes/claro/css/components/system-admin--modules.pcss.css b/core/themes/claro/css/components/system-admin--modules.pcss.css
index b25a543c3faf2fe46e93e43720644e13751635dc..da7b5aafe7b96005cdb4451b8f2dfae3ad69c94e 100644
--- a/core/themes/claro/css/components/system-admin--modules.pcss.css
+++ b/core/themes/claro/css/components/system-admin--modules.pcss.css
@@ -8,7 +8,7 @@
   --module-table-cell-padding-horizontal: calc(var(--space-m) - (var(--input-border-size) * 2));
 }
 
-.modules-table-filter {
+.modules-table-filter, .permissions-table-filter {
   padding: 0.25rem var(--space-l);
   border: 1px solid var(--color-gray-200);
   border-radius: 2px 2px 0 0;
@@ -16,7 +16,7 @@
 }
 
 /* Visually hide the module filter input description. */
-.modules-table-filter .form-item__description {
+.modules-table-filter .form-item__description, .permissions-table-filter .form-item__description {
   position: absolute !important;
   overflow: hidden;
   clip: rect(1px, 1px, 1px, 1px);