diff --git a/core/assets/scaffold/files/default.settings.php b/core/assets/scaffold/files/default.settings.php
index f43915bf11693ac786737ff836e3da862793aab3..800024129ec00a35bf9c19d39e5aa63a17828ef7 100644
--- a/core/assets/scaffold/files/default.settings.php
+++ b/core/assets/scaffold/files/default.settings.php
@@ -307,6 +307,20 @@
  */
 $settings['update_free_access'] = FALSE;
 
+/**
+ * Fallback to HTTP for Update Manager.
+ *
+ * If your Drupal site fails to connect to updates.drupal.org using HTTPS to
+ * fetch Drupal core, module and theme update status, you may uncomment this
+ * setting and set it to TRUE to allow an insecure fallback to HTTP. Note that
+ * doing so will open your site up to a potential man-in-the-middle attack. You
+ * should instead attempt to resolve the issues before enabling this option.
+ * @see https://www.drupal.org/docs/system-requirements/php-requirements#openssl
+ * @see https://en.wikipedia.org/wiki/Man-in-the-middle_attack
+ * @see \Drupal\update\UpdateFetcher
+ */
+# $settings['update_fetch_with_http_fallback'] = TRUE;
+
 /**
  * External access proxy settings:
  *
diff --git a/core/modules/migrate_drupal/tests/fixtures/drupal6.php b/core/modules/migrate_drupal/tests/fixtures/drupal6.php
index beab94a3a66c18706231b63e7fc02bfc759ad0ff..5918895b6fbffb885129076d3deeca600dd16d0e 100644
--- a/core/modules/migrate_drupal/tests/fixtures/drupal6.php
+++ b/core/modules/migrate_drupal/tests/fixtures/drupal6.php
@@ -47270,7 +47270,7 @@
   'name' => 'update',
   'type' => 'module',
   'owner' => '',
-  'status' => '0',
+  'status' => '1',
   'throttle' => '0',
   'bootstrap' => '0',
   'schema_version' => '-1',
diff --git a/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php b/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php
index 393540712053ec732379891b54b1f45ee2cd450c..6436b282b04fab0f43790ee87adefcd708b78c28 100644
--- a/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php
+++ b/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php
@@ -29,6 +29,7 @@ class Upgrade6Test extends MigrateUpgradeExecuteTestBase {
     'book',
     'forum',
     'statistics',
+    'update',
   ];
 
   /**
@@ -166,6 +167,7 @@ protected function getAvailablePaths() {
       'System',
       'Taxonomy',
       'Text',
+      'Update status',
       'Upload',
       'User',
       'User Reference',
diff --git a/core/modules/update/config/schema/update.source.schema.yml b/core/modules/update/config/schema/update.source.schema.yml
new file mode 100644
index 0000000000000000000000000000000000000000..02777ac809e5596e05f5d43c4aa926b4d6af61b1
--- /dev/null
+++ b/core/modules/update/config/schema/update.source.schema.yml
@@ -0,0 +1,12 @@
+# Schema for the migration source plugins.
+
+migrate.source.update_settings:
+  type: migrate_source_sql
+  label: 'Drupal update settings'
+  mapping:
+    variables:
+      type: sequence
+      label: 'Variables'
+      sequence:
+        type: string
+        label: 'Variable'
diff --git a/core/modules/update/migrations/update_settings.yml b/core/modules/update/migrations/update_settings.yml
index 4bd4a2b60f6ad0b490a2ad30d9793859b91b7c6f..6525a30e33e73094fb57a7c2a3a68b09e6e3d181 100644
--- a/core/modules/update/migrations/update_settings.yml
+++ b/core/modules/update/migrations/update_settings.yml
@@ -5,7 +5,7 @@ migration_tags:
   - Drupal 7
   - Configuration
 source:
-  plugin: variable
+  plugin: update_settings
   variables:
     - update_max_fetch_attempts
     - update_fetch_url
diff --git a/core/modules/update/src/Controller/UpdateController.php b/core/modules/update/src/Controller/UpdateController.php
index 6a8ec6abcbe8e8d295078ef34f42ae967e358e49..bf0f1684ecf6d54eb164abb2eb169d1f587bf89f 100644
--- a/core/modules/update/src/Controller/UpdateController.php
+++ b/core/modules/update/src/Controller/UpdateController.php
@@ -2,9 +2,11 @@
 
 namespace Drupal\update\Controller;
 
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\update\UpdateFetcherInterface;
 use Drupal\update\UpdateManagerInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
-use Drupal\Core\Controller\ControllerBase;
 
 /**
  * Controller routines for update routes.
@@ -18,14 +20,28 @@ class UpdateController extends ControllerBase {
    */
   protected $updateManager;
 
+  /**
+   * The renderer.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
   /**
    * Constructs update status data.
    *
    * @param \Drupal\update\UpdateManagerInterface $update_manager
    *   Update Manager Service.
+   * @param \Drupal\Core\Render\RendererInterface|null $renderer
+   *   The renderer.
    */
-  public function __construct(UpdateManagerInterface $update_manager) {
+  public function __construct(UpdateManagerInterface $update_manager, RendererInterface $renderer = NULL) {
     $this->updateManager = $update_manager;
+    if (is_null($renderer)) {
+      @trigger_error('The renderer service should be passed to UpdateController::__construct() since 9.1.0. This will be required in Drupal 10.0.0. See https://www.drupal.org/node/3179315', E_USER_DEPRECATED);
+      $renderer = \Drupal::service('renderer');
+    }
+    $this->renderer = $renderer;
   }
 
   /**
@@ -33,7 +49,8 @@ public function __construct(UpdateManagerInterface $update_manager) {
    */
   public static function create(ContainerInterface $container) {
     return new static(
-      $container->get('update.manager')
+      $container->get('update.manager'),
+      $container->get('renderer')
     );
   }
 
@@ -50,6 +67,20 @@ public function updateStatus() {
     if ($available = update_get_available(TRUE)) {
       $this->moduleHandler()->loadInclude('update', 'compare.inc');
       $build['#data'] = update_calculate_project_data($available);
+
+      // @todo Consider using 'fetch_failures' from the 'update' collection
+      // in the key_value_expire service for this?
+      $fetch_failed = FALSE;
+      foreach ($build['#data'] as $project) {
+        if ($project['status'] === UpdateFetcherInterface::NOT_FETCHED) {
+          $fetch_failed = TRUE;
+          break;
+        }
+      }
+      if ($fetch_failed) {
+        $message = ['#theme' => 'update_fetch_error_message'];
+        $this->messenger()->addError($this->renderer->renderPlain($message));
+      }
     }
     return $build;
   }
diff --git a/core/modules/update/src/Form/UpdateManagerUpdate.php b/core/modules/update/src/Form/UpdateManagerUpdate.php
index 6e23903322a0552b545f55b2144e49f92dd97e18..aeabd99a8311c9b2d00d813edee46483f40b543d 100644
--- a/core/modules/update/src/Form/UpdateManagerUpdate.php
+++ b/core/modules/update/src/Form/UpdateManagerUpdate.php
@@ -104,7 +104,13 @@ public function buildForm(array $form, FormStateInterface $form_state) {
     $form['project_downloads'] = ['#tree' => TRUE];
     $this->moduleHandler->loadInclude('update', 'inc', 'update.compare');
     $project_data = update_calculate_project_data($available);
+
+    $fetch_failed = FALSE;
     foreach ($project_data as $name => $project) {
+      if ($project['status'] === UpdateFetcherInterface::NOT_FETCHED) {
+        $fetch_failed = TRUE;
+      }
+
       // Filter out projects which are up to date already.
       if ($project['status'] == UpdateManagerInterface::CURRENT) {
         continue;
@@ -245,6 +251,11 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       }
     }
 
+    if ($fetch_failed) {
+      $message = ['#theme' => 'update_fetch_error_message'];
+      $this->messenger()->addError(\Drupal::service('renderer')->renderPlain($message));
+    }
+
     if (empty($projects)) {
       $form['message'] = [
         '#markup' => $this->t('All of your projects are up to date.'),
diff --git a/core/modules/update/src/Plugin/migrate/source/UpdateSettings.php b/core/modules/update/src/Plugin/migrate/source/UpdateSettings.php
new file mode 100644
index 0000000000000000000000000000000000000000..400133847612e596ff724f6974cf77e72fd53f24
--- /dev/null
+++ b/core/modules/update/src/Plugin/migrate/source/UpdateSettings.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\update\Plugin\migrate\source;
+
+use Drupal\migrate_drupal\Plugin\migrate\source\Variable;
+
+/**
+ * Update settings source plugin.
+ *
+ * @MigrateSource(
+ *   id = "update_settings",
+ *   source_module = "update"
+ * )
+ */
+class UpdateSettings extends Variable {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function values() {
+    $values = parent::values();
+    if (empty($values['update_fetch_url']) || strpos($values['update_fetch_url'], 'http://updates.drupal.org/release-history') !== FALSE) {
+      $values['update_fetch_url'] = 'https://updates.drupal.org/release-history';
+    }
+    return $values;
+  }
+
+}
diff --git a/core/modules/update/src/UpdateFetcher.php b/core/modules/update/src/UpdateFetcher.php
index 05cce763a67d38b4884e499c523a9be6a4cb02af..015b354a2c8d6b574932e0e45e0296a8df7011ac 100644
--- a/core/modules/update/src/UpdateFetcher.php
+++ b/core/modules/update/src/UpdateFetcher.php
@@ -4,6 +4,7 @@
 
 use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\DependencyInjection\DependencySerializationTrait;
+use Drupal\Core\Site\Settings;
 use GuzzleHttp\ClientInterface;
 use GuzzleHttp\Exception\RequestException;
 
@@ -17,7 +18,7 @@ class UpdateFetcher implements UpdateFetcherInterface {
   /**
    * URL to check for updates, if a given project doesn't define its own.
    */
-  const UPDATE_DEFAULT_URL = 'http://updates.drupal.org/release-history';
+  const UPDATE_DEFAULT_URL = 'https://updates.drupal.org/release-history';
 
   /**
    * The fetch url configured in the update settings.
@@ -40,6 +41,13 @@ class UpdateFetcher implements UpdateFetcherInterface {
    */
   protected $httpClient;
 
+  /**
+   * Whether to use HTTP fallback if HTTPS fails.
+   *
+   * @var bool
+   */
+  protected $withHttpFallback;
+
   /**
    * Constructs a UpdateFetcher.
    *
@@ -47,11 +55,18 @@ class UpdateFetcher implements UpdateFetcherInterface {
    *   The config factory.
    * @param \GuzzleHttp\ClientInterface $http_client
    *   A Guzzle client object.
+   * @param \Drupal\Core\Site\Settings|null $settings
+   *   The settings instance.
    */
-  public function __construct(ConfigFactoryInterface $config_factory, ClientInterface $http_client) {
+  public function __construct(ConfigFactoryInterface $config_factory, ClientInterface $http_client, Settings $settings = NULL) {
     $this->fetchUrl = $config_factory->get('update.settings')->get('fetch.url');
     $this->httpClient = $http_client;
     $this->updateSettings = $config_factory->get('update.settings');
+    if (is_null($settings)) {
+      @trigger_error('The settings service should be passed to UpdateFetcher::__construct() since 9.1.0. This will be required in Drupal 10.0.0. See https://www.drupal.org/node/3179315', E_USER_DEPRECATED);
+      $settings = \Drupal::service('settings');
+    }
+    $this->withHttpFallback = $settings->get('update_fetch_with_http_fallback', FALSE);
   }
 
   /**
@@ -59,6 +74,26 @@ public function __construct(ConfigFactoryInterface $config_factory, ClientInterf
    */
   public function fetchProjectData(array $project, $site_key = '') {
     $url = $this->buildFetchUrl($project, $site_key);
+    return $this->doRequest($url, ['headers' => ['Accept' => 'text/xml']], $this->withHttpFallback);
+  }
+
+  /**
+   * Applies a GET request with a possible HTTP fallback.
+   *
+   * This method falls back to HTTP in case there was some certificate
+   * problem.
+   *
+   * @param string $url
+   *   The URL.
+   * @param array $options
+   *   The guzzle client options.
+   * @param bool $with_http_fallback
+   *   Should the function fall back to HTTP.
+   *
+   * @return string
+   *   The body of the HTTP(S) request, or an empty string on failure.
+   */
+  protected function doRequest(string $url, array $options, bool $with_http_fallback): string {
     $data = '';
     try {
       $data = (string) $this->httpClient
@@ -67,6 +102,10 @@ public function fetchProjectData(array $project, $site_key = '') {
     }
     catch (RequestException $exception) {
       watchdog_exception('update', $exception);
+      if ($with_http_fallback && strpos($url, "http://") === FALSE) {
+        $url = str_replace('https://', 'http://', $url);
+        return $this->doRequest($url, $options, FALSE);
+      }
     }
     return $data;
   }
diff --git a/core/modules/update/templates/update-fetch-error-message.html.twig b/core/modules/update/templates/update-fetch-error-message.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..fd4a967e72566ce975e796f96950af6b39d90f8c
--- /dev/null
+++ b/core/modules/update/templates/update-fetch-error-message.html.twig
@@ -0,0 +1,14 @@
+{#
+/**
+ * @file
+ * Default theme implementation for the message when fetching data fails.
+ *
+ * Available variables:
+ * - error_message: A render array containing the appropriate error message.
+ *
+ * @see template_preprocess_update_fetch_error_message()
+ *
+ * @ingroup themeable
+ */
+#}
+{{ error_message }}
diff --git a/core/modules/update/tests/src/Kernel/Migrate/d6/MigrateUpdateConfigsTest.php b/core/modules/update/tests/src/Kernel/Migrate/d6/MigrateUpdateConfigsTest.php
index 7132546aadae24d2b36633a496ef68fbb6c73547..cea75f1fce79afdee176fd000b7f9644c5206713 100644
--- a/core/modules/update/tests/src/Kernel/Migrate/d6/MigrateUpdateConfigsTest.php
+++ b/core/modules/update/tests/src/Kernel/Migrate/d6/MigrateUpdateConfigsTest.php
@@ -33,7 +33,7 @@ protected function setUp(): void {
   public function testUpdateSettings() {
     $config = $this->config('update.settings');
     $this->assertIdentical(2, $config->get('fetch.max_attempts'));
-    $this->assertIdentical('http://updates.drupal.org/release-history', $config->get('fetch.url'));
+    $this->assertIdentical('https://updates.drupal.org/release-history', $config->get('fetch.url'));
     $this->assertIdentical('all', $config->get('notification.threshold'));
     $this->assertIdentical([], $config->get('notification.emails'));
     $this->assertIdentical(7, $config->get('check.interval_days'));
diff --git a/core/modules/update/tests/src/Kernel/UpdateReportTest.php b/core/modules/update/tests/src/Kernel/UpdateReportTest.php
index 69aab606a175ae70139ddeb028300906051788fa..522c89f93722dd2d1a1676e926b7a85a8c285e4a 100644
--- a/core/modules/update/tests/src/Kernel/UpdateReportTest.php
+++ b/core/modules/update/tests/src/Kernel/UpdateReportTest.php
@@ -2,7 +2,10 @@
 
 namespace Drupal\Tests\update\Kernel;
 
+use Drupal\Core\Link;
+use Drupal\Core\Url;
 use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\user\Traits\UserCreationTrait;
 
 /**
  * Tests update report functionality.
@@ -12,6 +15,8 @@
  */
 class UpdateReportTest extends KernelTestBase {
 
+  use UserCreationTrait;
+
   /**
    * {@inheritdoc}
    */
@@ -54,4 +59,72 @@ public function providerTemplatePreprocessUpdateReport() {
     ];
   }
 
+  /**
+   * Tests the error message when failing to fetch data without dblog enabled.
+   *
+   * @see template_preprocess_update_fetch_error_message()
+   */
+  public function testTemplatePreprocessUpdateFetchErrorMessageNoDblog() {
+    $build = [
+      '#theme' => 'update_fetch_error_message',
+    ];
+    $this->render($build);
+    $this->assertRaw('Failed to fetch available update data:<ul><li>See <a href="https://www.drupal.org/node/3170647">PHP OpenSSL requirements</a> in the Drupal.org handbook for possible reasons this could happen and what you can do to resolve them.</li><li>Check your local system logs for additional error messages.</li></ul>');
+
+    \Drupal::moduleHandler()->loadInclude('update', 'inc', 'update.report');
+    $variables = [];
+    template_preprocess_update_fetch_error_message($variables);
+    $this->assertArrayHasKey('error_message', $variables);
+    $this->assertEquals('Failed to fetch available update data:', $variables['error_message']['message']['#markup']);
+    $this->assertArrayHasKey('documentation_link', $variables['error_message']['items']['#items']);
+    $this->assertArrayHasKey('logs', $variables['error_message']['items']['#items']);
+    $this->assertArrayNotHasKey('dblog', $variables['error_message']['items']['#items']);
+  }
+
+  /**
+   * Tests the error message when failing to fetch data with dblog enabled.
+   *
+   * @see template_preprocess_update_fetch_error_message()
+   */
+  public function testTemplatePreprocessUpdateFetchErrorMessageWithDblog() {
+    \Drupal::moduleHandler()->loadInclude('update', 'inc', 'update.report');
+
+    $this->enableModules(['dblog', 'user']);
+    $this->installEntitySchema('user');
+
+    // First, try as a normal user that can't access dblog.
+    $this->setUpCurrentUser();
+
+    $build = [
+      '#theme' => 'update_fetch_error_message',
+    ];
+    $this->render($build);
+    $this->assertRaw('Failed to fetch available update data:<ul><li>See <a href="https://www.drupal.org/node/3170647">PHP OpenSSL requirements</a> in the Drupal.org handbook for possible reasons this could happen and what you can do to resolve them.</li><li>Check your local system logs for additional error messages.</li></ul>');
+
+    $variables = [];
+    template_preprocess_update_fetch_error_message($variables);
+    $this->assertArrayHasKey('error_message', $variables);
+    $this->assertEquals('Failed to fetch available update data:', $variables['error_message']['message']['#markup']);
+    $this->assertArrayHasKey('documentation_link', $variables['error_message']['items']['#items']);
+    $this->assertArrayHasKey('logs', $variables['error_message']['items']['#items']);
+    $this->assertArrayNotHasKey('dblog', $variables['error_message']['items']['#items']);
+
+    // Now, try as an admin that can access dblog.
+    $this->setUpCurrentUser([], ['access content', 'access site reports']);
+
+    $this->render($build);
+    $this->assertRaw('Failed to fetch available update data:<ul><li>See <a href="https://www.drupal.org/node/3170647">PHP OpenSSL requirements</a> in the Drupal.org handbook for possible reasons this could happen and what you can do to resolve them.</li><li>Check');
+    $dblog_url = Url::fromRoute('dblog.overview', [], ['query' => ['type' => ['update']]]);
+    $this->assertRaw(Link::fromTextAndUrl('your local system logs', $dblog_url)->toString());
+    $this->assertRaw(' for additional error messages.</li></ul>');
+
+    $variables = [];
+    template_preprocess_update_fetch_error_message($variables);
+    $this->assertArrayHasKey('error_message', $variables);
+    $this->assertEquals('Failed to fetch available update data:', $variables['error_message']['message']['#markup']);
+    $this->assertArrayHasKey('documentation_link', $variables['error_message']['items']['#items']);
+    $this->assertArrayNotHasKey('logs', $variables['error_message']['items']['#items']);
+    $this->assertArrayHasKey('dblog', $variables['error_message']['items']['#items']);
+  }
+
 }
diff --git a/core/modules/update/tests/src/Unit/UpdateFetcherTest.php b/core/modules/update/tests/src/Unit/UpdateFetcherTest.php
index e0a56928ca5e376cc0eeb008f8bcc309636ab048..c530848a2dfc101637058b8907974a7074e14a8b 100644
--- a/core/modules/update/tests/src/Unit/UpdateFetcherTest.php
+++ b/core/modules/update/tests/src/Unit/UpdateFetcherTest.php
@@ -2,15 +2,27 @@
 
 namespace Drupal\Tests\update\Unit;
 
+use Drupal\Core\Logger\LoggerChannelFactory;
+use Drupal\Core\Logger\RfcLoggerTrait;
+use Drupal\Core\Site\Settings;
 use Drupal\Tests\UnitTestCase;
 use Drupal\update\UpdateFetcher;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+use GuzzleHttp\Psr7\Response;
+use Psr\Log\LoggerInterface;
 
 /**
  * Tests update functionality unrelated to the database.
  *
+ * @coversDefaultClass \Drupal\update\UpdateFetcher
+ *
  * @group update
  */
-class UpdateFetcherTest extends UnitTestCase {
+class UpdateFetcherTest extends UnitTestCase implements LoggerInterface {
+  use RfcLoggerTrait;
 
   /**
    * The update fetcher to use.
@@ -19,13 +31,68 @@ class UpdateFetcherTest extends UnitTestCase {
    */
   protected $updateFetcher;
 
+  /**
+   * History of requests/responses.
+   *
+   * @var array
+   */
+  protected $history = [];
+
+  /**
+   * Mock HTTP client.
+   *
+   * @var \GuzzleHttp\ClientInterface
+   */
+  protected $mockHttpClient;
+
+  /**
+   * Mock config factory.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $mockConfigFactory;
+
+  /**
+   * A test project to fetch with.
+   *
+   * @var array
+   */
+  protected $testProject;
+
+  /**
+   * @var array
+   */
+  protected $logMessages = [];
+
   /**
    * {@inheritdoc}
    */
   protected function setUp(): void {
-    $config_factory = $this->getConfigFactoryStub(['update.settings' => ['fetch_url' => 'http://www.example.com']]);
-    $http_client_mock = $this->createMock('\GuzzleHttp\ClientInterface');
-    $this->updateFetcher = new UpdateFetcher($config_factory, $http_client_mock);
+    parent::setUp();
+    $this->mockConfigFactory = $this->getConfigFactoryStub(['update.settings' => ['fetch_url' => 'http://www.example.com']]);
+    $this->mockHttpClient = $this->createMock('\GuzzleHttp\ClientInterface');
+    $settings = new Settings([]);
+    $this->updateFetcher = new UpdateFetcher($this->mockConfigFactory, $this->mockHttpClient, $settings);
+    $this->testProject = [
+      'name' => 'update_test',
+      'project_type' => '',
+      'info' => [
+        'version' => '',
+        'project status url' => 'https://www.example.com',
+      ],
+      'includes' => ['module1' => 'Module 1', 'module2' => 'Module 2'],
+    ];
+
+    // Set up logger factory so that watchdog_exception() does not break and
+    // register this class as the logger so we can test messages.
+    $container = $this->createMock('Symfony\Component\DependencyInjection\ContainerInterface');
+    $logger_factory = new LoggerChannelFactory();
+    $logger_factory->addLogger($this);
+    $container->expects($this->any())
+      ->method('get')
+      ->with('logger.factory')
+      ->will($this->returnValue($logger_factory));
+    \Drupal::setContainer($container);
   }
 
   /**
@@ -46,6 +113,7 @@ protected function setUp(): void {
   public function testUpdateBuildFetchUrl(array $project, $site_key, $expected) {
     $url = $this->updateFetcher->buildFetchUrl($project, $site_key);
     $this->assertEquals($url, $expected);
+    $this->assertSame([], $this->logMessages);
   }
 
   /**
@@ -97,4 +165,86 @@ public function providerTestUpdateBuildFetchUrl() {
     return $data;
   }
 
+  /**
+   * Mocks the HTTP client.
+   *
+   * @param \GuzzleHttp\Psr7\Response ...
+   *   Variable number of Response objects that the mocked client should return.
+   */
+  protected function mockClient(Response ...$responses) {
+    // Create a mock and queue responses.
+    $mock_handler = new MockHandler($responses);
+    $handler_stack = HandlerStack::create($mock_handler);
+    $history = Middleware::history($this->history);
+    $handler_stack->push($history);
+    $this->mockHttpClient = new Client(['handler' => $handler_stack]);
+  }
+
+  /**
+   * @covers ::doRequest
+   * @covers ::fetchProjectData
+   */
+  public function testUpdateFetcherNoFallback() {
+    // First, try without the HTTP fallback setting, and HTTPS mocked to fail.
+    $settings = new Settings([]);
+    $this->mockClient(
+      new Response('500', [], 'HTTPS failed'),
+    );
+    $update_fetcher = new UpdateFetcher($this->mockConfigFactory, $this->mockHttpClient, $settings);
+
+    $data = $update_fetcher->fetchProjectData($this->testProject, '');
+    // There should only be one request / response pair.
+    $this->assertCount(1, $this->history);
+    $request = $this->history[0]['request'];
+    $this->assertNotEmpty($request);
+    // It should have only been an HTTPS request.
+    $this->assertEquals('https', $request->getUri()->getScheme());
+    // And it should have failed.
+    $response = $this->history[0]['response'];
+    $this->assertEquals(500, $response->getStatusCode());
+    $this->assertEmpty($data);
+    $this->assertSame(["Server error: `GET https://www.example.com/update_test/current` resulted in a `500 Internal Server Error` response:\nHTTPS failed\n"], $this->logMessages);
+  }
+
+  /**
+   * @covers ::doRequest
+   * @covers ::fetchProjectData
+   */
+  public function testUpdateFetcherHttpFallback() {
+    $settings = new Settings(['update_fetch_with_http_fallback' => TRUE]);
+    $this->mockClient(
+      new Response('500', [], 'HTTPS failed'),
+      new Response('200', [], 'HTTP worked'),
+    );
+    $update_fetcher = new UpdateFetcher($this->mockConfigFactory, $this->mockHttpClient, $settings);
+
+    $data = $update_fetcher->fetchProjectData($this->testProject, '');
+
+    // There should be two request / response pairs.
+    $this->assertCount(2, $this->history);
+
+    // The first should have been HTTPS and should have failed.
+    $first_try = $this->history[0];
+    $this->assertNotEmpty($first_try);
+    $this->assertEquals('https', $first_try['request']->getUri()->getScheme());
+    $this->assertEquals(500, $first_try['response']->getStatusCode());
+
+    // The second should have been the HTTP fallback and should have worked.
+    $second_try = $this->history[1];
+    $this->assertNotEmpty($second_try);
+    $this->assertEquals('http', $second_try['request']->getUri()->getScheme());
+    $this->assertEquals(200, $second_try['response']->getStatusCode());
+    // Although this is a bogus mocked response, it's what fetchProjectData()
+    // should return in this case.
+    $this->assertEquals('HTTP worked', $data);
+    $this->assertSame(["Server error: `GET https://www.example.com/update_test/current` resulted in a `500 Internal Server Error` response:\nHTTPS failed\n"], $this->logMessages);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function log($level, $message, array $context = []) {
+    $this->logMessages[] = $context['@message'];
+  }
+
 }
diff --git a/core/modules/update/update.module b/core/modules/update/update.module
index d777a0669e15144c93ae00a8de1bcf22c3f7c718..d6107962d606ef65852f8044a0ac5eeee19fc88d 100644
--- a/core/modules/update/update.module
+++ b/core/modules/update/update.module
@@ -161,6 +161,11 @@ function update_theme() {
       'variables' => ['version' => NULL, 'title' => NULL, 'attributes' => []],
       'file' => 'update.report.inc',
     ],
+    'update_fetch_error_message' => [
+      'file' => 'update.report.inc',
+      'render element' => 'element',
+      'variables' => ['error_message' => []],
+    ],
   ];
 }
 
diff --git a/core/modules/update/update.report.inc b/core/modules/update/update.report.inc
index 172860f3fda9166adb6fd898d19c810eec5a2e0e..7e65011954b7ef06e0ee5d0361903558e5bc2567 100644
--- a/core/modules/update/update.report.inc
+++ b/core/modules/update/update.report.inc
@@ -334,3 +334,34 @@ function template_preprocess_update_project_status(&$variables) {
     '#title' => $text,
   ];
 }
+
+/**
+ * Prepares variables for update fetch error message templates.
+ *
+ * Default template: update-fetch-error-message.html.twig.
+ *
+ * @param array $variables
+ *   An associative array of template variables.
+ */
+function template_preprocess_update_fetch_error_message(&$variables): void {
+  $variables['error_message'] = [
+    'message' => [
+      '#markup' => t('Failed to fetch available update data:'),
+    ],
+    'items' => [
+      '#theme' => 'item_list',
+      '#items' => [
+        'documentation_link' => t('See <a href="@url">PHP OpenSSL requirements</a> in the Drupal.org handbook for possible reasons this could happen and what you can do to resolve them.', ['@url' => 'https://www.drupal.org/node/3170647']),
+      ],
+    ],
+  ];
+  if (\Drupal::moduleHandler()->moduleExists('dblog') && \Drupal::currentUser()->hasPermission('access site reports')) {
+    $options = ['query' => ['type' => ['update']]];
+    $dblog_url = Url::fromRoute('dblog.overview', [], $options);
+    $variables['error_message']['items']['#items']['dblog'] = t('Check <a href="@url">your local system logs</a> for additional error messages.', ['@url' => $dblog_url->toString()]);
+  }
+  else {
+    $variables['error_message']['items']['#items']['logs'] = t('Check your local system logs for additional error messages.');
+  }
+
+}
diff --git a/core/modules/update/update.services.yml b/core/modules/update/update.services.yml
index 197e3d5f1d39eea4d3c2599d248f5153b837544d..df328443cd6129e00a2cb6fcc069dd76f5be4f71 100644
--- a/core/modules/update/update.services.yml
+++ b/core/modules/update/update.services.yml
@@ -12,7 +12,7 @@ services:
     arguments: ['@config.factory', '@queue', '@update.fetcher', '@state', '@private_key', '@keyvalue', '@keyvalue.expirable']
   update.fetcher:
     class: Drupal\update\UpdateFetcher
-    arguments: ['@config.factory', '@http_client']
+    arguments: ['@config.factory', '@http_client', '@settings']
   update.root:
     class: SplString
     factory: ['@update.root.factory', 'get']
diff --git a/core/themes/stable/templates/admin/update-fetch-error-message.html.twig b/core/themes/stable/templates/admin/update-fetch-error-message.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..fd4a967e72566ce975e796f96950af6b39d90f8c
--- /dev/null
+++ b/core/themes/stable/templates/admin/update-fetch-error-message.html.twig
@@ -0,0 +1,14 @@
+{#
+/**
+ * @file
+ * Default theme implementation for the message when fetching data fails.
+ *
+ * Available variables:
+ * - error_message: A render array containing the appropriate error message.
+ *
+ * @see template_preprocess_update_fetch_error_message()
+ *
+ * @ingroup themeable
+ */
+#}
+{{ error_message }}
diff --git a/core/themes/stable9/templates/admin/update-fetch-error-message.html.twig b/core/themes/stable9/templates/admin/update-fetch-error-message.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..fd4a967e72566ce975e796f96950af6b39d90f8c
--- /dev/null
+++ b/core/themes/stable9/templates/admin/update-fetch-error-message.html.twig
@@ -0,0 +1,14 @@
+{#
+/**
+ * @file
+ * Default theme implementation for the message when fetching data fails.
+ *
+ * Available variables:
+ * - error_message: A render array containing the appropriate error message.
+ *
+ * @see template_preprocess_update_fetch_error_message()
+ *
+ * @ingroup themeable
+ */
+#}
+{{ error_message }}
diff --git a/sites/default/default.settings.php b/sites/default/default.settings.php
index f43915bf11693ac786737ff836e3da862793aab3..800024129ec00a35bf9c19d39e5aa63a17828ef7 100644
--- a/sites/default/default.settings.php
+++ b/sites/default/default.settings.php
@@ -307,6 +307,20 @@
  */
 $settings['update_free_access'] = FALSE;
 
+/**
+ * Fallback to HTTP for Update Manager.
+ *
+ * If your Drupal site fails to connect to updates.drupal.org using HTTPS to
+ * fetch Drupal core, module and theme update status, you may uncomment this
+ * setting and set it to TRUE to allow an insecure fallback to HTTP. Note that
+ * doing so will open your site up to a potential man-in-the-middle attack. You
+ * should instead attempt to resolve the issues before enabling this option.
+ * @see https://www.drupal.org/docs/system-requirements/php-requirements#openssl
+ * @see https://en.wikipedia.org/wiki/Man-in-the-middle_attack
+ * @see \Drupal\update\UpdateFetcher
+ */
+# $settings['update_fetch_with_http_fallback'] = TRUE;
+
 /**
  * External access proxy settings:
  *