diff --git a/core/includes/form.inc b/core/includes/form.inc
index 2a801814bde27113be245e2a402ae3cfe2dbc8e6..95c1564eded44efc9d874388477a7d4fe3480be0 100644
--- a/core/includes/form.inc
+++ b/core/includes/form.inc
@@ -423,11 +423,14 @@ function template_preprocess_textarea(&$variables) {
  * but the parent element should have neither. Use this carefully because a
  * field without an associated label can cause accessibility challenges.
  *
+ * To associate the label with a different field, set the #label_for property
+ * to the ID of the desired field.
+ *
  * @param array $variables
  *   An associative array containing:
  *   - element: An associative array containing the properties of the element.
  *     Properties used: #title, #title_display, #description, #id, #required,
- *     #children, #type, #name.
+ *     #children, #type, #name, #label_for.
  */
 function template_preprocess_form_element(&$variables) {
   $element = &$variables['element'];
@@ -439,6 +442,7 @@ function template_preprocess_form_element(&$variables) {
     '#title_display' => 'before',
     '#wrapper_attributes' => [],
     '#label_attributes' => [],
+    '#label_for' => NULL,
   ];
   $variables['attributes'] = $element['#wrapper_attributes'];
 
@@ -487,6 +491,12 @@ function template_preprocess_form_element(&$variables) {
   $variables['label'] = ['#theme' => 'form_element_label'];
   $variables['label'] += array_intersect_key($element, array_flip(['#id', '#required', '#title', '#title_display']));
   $variables['label']['#attributes'] = $element['#label_attributes'];
+  if (!empty($element['#label_for'])) {
+    $variables['label']['#for'] = $element['#label_for'];
+    if (!empty($element['#id'])) {
+      $variables['label']['#id'] = $element['#id'] . '--label';
+    }
+  }
 
   $variables['children'] = $element['#children'];
 }
@@ -507,10 +517,13 @@ function template_preprocess_form_element(&$variables) {
  * required. That is especially important for screenreader users to know
  * which field is required.
  *
+ * To associate the label with a different field, set the #for property to the
+ * ID of the desired field.
+ *
  * @param array $variables
  *   An associative array containing:
  *   - element: An associative array containing the properties of the element.
- *     Properties used: #required, #title, #id, #value, #description.
+ *     Properties used: #required, #title, #id, #value, #description, #for.
  */
 function template_preprocess_form_element_label(&$variables) {
   $element = $variables['element'];
diff --git a/core/modules/file/src/Element/ManagedFile.php b/core/modules/file/src/Element/ManagedFile.php
index e4e0fa19970537984d2d0b6f8ff8435e378acf5f..b26118916e577f991ea0cfecbd40d34d66018205 100644
--- a/core/modules/file/src/Element/ManagedFile.php
+++ b/core/modules/file/src/Element/ManagedFile.php
@@ -305,12 +305,24 @@ public static function processManagedFile(&$element, FormStateInterface $form_st
       $element['upload_button']['#ajax']['event'] = 'fileUpload';
     }
 
+    // Use a manually generated ID for the file upload field so the desired
+    // field label can be associated with it below. Use the same method for
+    // setting the ID that the form API autogenerator does.
+    // @see \Drupal\Core\Form\FormBuilder::doBuildForm()
+    $id = Html::getUniqueId('edit-' . implode('-', array_merge($element['#parents'], ['upload'])));
+
     // The file upload field itself.
     $element['upload'] = [
       '#name' => 'files[' . $parents_prefix . ']',
       '#type' => 'file',
+      // This #title will not actually be used as the upload field's HTML label,
+      // since the theme function for upload fields never passes the element
+      // through theme('form_element'). Instead the parent element's #title is
+      // used as the label (see below). That is usually a more meaningful label
+      // anyway.
       '#title' => t('Choose a file'),
       '#title_display' => 'invisible',
+      '#id' => $id,
       '#size' => $element['#size'],
       '#multiple' => $element['#multiple'],
       '#theme_wrappers' => [],
@@ -321,6 +333,10 @@ public static function processManagedFile(&$element, FormStateInterface $form_st
       $element['upload']['#attributes'] = ['accept' => $element['#accept']];
     }
 
+    // Indicate that $element['#title'] should be used as the HTML label for the
+    // file upload field.
+    $element['#label_for'] = $element['upload']['#id'];
+
     if (!empty($fids) && $element['#files']) {
       foreach ($element['#files'] as $delta => $file) {
         $file_link = [
@@ -353,13 +369,9 @@ public static function processManagedFile(&$element, FormStateInterface $form_st
     // Add the extension list to the page as JavaScript settings.
     if (isset($element['#upload_validators']['file_validate_extensions'][0])) {
       $extension_list = implode(',', array_filter(explode(' ', $element['#upload_validators']['file_validate_extensions'][0])));
-      $element['upload']['#attached']['drupalSettings']['file']['elements']['#' . $element['#id']] = $extension_list;
+      $element['upload']['#attached']['drupalSettings']['file']['elements']['#' . $id] = $extension_list;
     }
 
-    // Let #id point to the file element, so the field label's 'for' corresponds
-    // with it.
-    $element['#id'] = &$element['upload']['#id'];
-
     // Prefix and suffix used for Ajax replacement.
     $element['#prefix'] = '<div id="' . $ajax_wrapper_id . '">';
     $element['#suffix'] = '</div>';
diff --git a/core/modules/file/tests/src/Functional/FileFieldDisplayTest.php b/core/modules/file/tests/src/Functional/FileFieldDisplayTest.php
index 07ed6a19cf321dfb635bd170d653f51117dfbb4d..a5c4ebfe8bdd44c83c967a66519eff681288cf59 100644
--- a/core/modules/file/tests/src/Functional/FileFieldDisplayTest.php
+++ b/core/modules/file/tests/src/Functional/FileFieldDisplayTest.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\Tests\file\Functional;
 
+use Drupal\Component\Render\FormattableMarkup;
 use Drupal\Core\Field\FieldStorageDefinitionInterface;
 use Drupal\file\Entity\File;
 use Drupal\node\Entity\Node;
@@ -107,6 +108,9 @@ public function testNodeDisplay() {
     $this->assertRaw($field_name . '[0][display]', 'First file appears as expected.');
     $this->assertRaw($field_name . '[1][display]', 'Second file appears as expected.');
     $this->assertSession()->responseContains($field_name . '[1][description]', 'Description of second file appears as expected.');
+
+    // Check that the file fields don't contain duplicate HTML IDs.
+    $this->assertNoDuplicateIds();
   }
 
   /**
@@ -220,4 +224,32 @@ public function testDescriptionDefaultFileFieldDisplay() {
     $this->assertFieldByXPath('//a[@href="' . $node->{$field_name}->entity->url() . '"]', $description);
   }
 
+  /**
+   * Asserts that each HTML ID is used for just a single element on the page.
+   *
+   * @param string $message
+   *   (optional) A message to display with the assertion.
+   */
+  protected function assertNoDuplicateIds($message = '') {
+    $args = ['@url' => $this->getUrl()];
+
+    if (!$elements = $this->xpath('//*[@id]')) {
+      $this->fail(new FormattableMarkup('The page @url contains no HTML IDs.', $args));
+      return;
+    }
+
+    $message = $message ?: new FormattableMarkup('The page @url does not contain duplicate HTML IDs', $args);
+
+    $seen_ids = [];
+    foreach ($elements as $element) {
+      $id = $element->getAttribute('id');
+      if (isset($seen_ids[$id])) {
+        $this->fail($message);
+        return;
+      }
+      $seen_ids[$id] = TRUE;
+    }
+    $this->assertTrue(TRUE, $message);
+  }
+
 }