diff --git a/core/includes/form.inc b/core/includes/form.inc
index 05f2a9e1fc..9e48ba5b09 100644
--- a/core/includes/form.inc
+++ b/core/includes/form.inc
@@ -416,12 +416,15 @@ function template_preprocess_textarea(&$variables) {
  * creates children elements that have their own labels and required markers,
  * 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'];
@@ -433,6 +436,7 @@ function template_preprocess_form_element(&$variables) {
     '#title_display' => 'before',
     '#wrapper_attributes' => [],
     '#label_attributes' => [],
+    '#label_for' => [],
   ];
   $variables['attributes'] = $element['#wrapper_attributes'];
 
@@ -481,6 +485,7 @@ 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'];
+  $variables['label']['#for'] = $element['#label_for'];
 
   $variables['children'] = $element['#children'];
 }
@@ -501,10 +506,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 45c8aa7cec..6a331554e7 100644
--- a/core/modules/file/src/Element/ManagedFile.php
+++ b/core/modules/file/src/Element/ManagedFile.php
@@ -297,12 +297,24 @@ public static function processManagedFile(&$element, FormStateInterface $form_st
       $element['upload_button']['#ajax']['progress']['url'] = Url::fromRoute('file.ajax_progress', ['key' => $upload_progress_key]);
     }
 
+    // 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' => [],
@@ -313,6 +325,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 = [
@@ -345,13 +361,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>';
