diff --git a/core/includes/bootstrap.inc b/core/includes/bootstrap.inc
index f65c3d1f06..ada0d0ab08 100644
--- a/core/includes/bootstrap.inc
+++ b/core/includes/bootstrap.inc
@@ -5,18 +5,19 @@
  * Functions that need to be loaded on every Drupal request.
  */
 
-use Drupal\Component\Utility\Crypt;
 use Drupal\Component\Utility\Html;
 use Drupal\Component\Render\FormattableMarkup;
 use Drupal\Component\Utility\Unicode;
 use Drupal\Core\Config\BootstrapConfigStorageFactory;
+use Drupal\Core\DependencyInjection\ContainerNotInitializedException;
 use Drupal\Core\Logger\RfcLogLevel;
-use Drupal\Core\Test\TestDatabase;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\Core\Site\Settings;
+use Drupal\Core\Test\UserAgent;
 use Drupal\Core\Utility\Error;
 use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
+use Symfony\Component\HttpFoundation\Request;
 
 /**
  * Minimum allowed version of PHP.
@@ -642,97 +643,47 @@ function _drupal_exception_handler_additional($exception, $exception2) {
  *
  * @param string $new_prefix
  *   Internal use only. A new prefix to be stored.
+ * @param \Symfony\Component\HttpFoundation\Request|null $request
+ *   (optional) Request instance.
  *
  * @return string|false
  *   Either the simpletest prefix (the string "simpletest" followed by any
  *   number of digits) or FALSE if the user agent does not contain a valid
  *   HMAC and timestamp.
+ *
+ * @deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use
+ *   \Drupal\Core\Test\UserAgent::validate().
+ *
+ * @see https://www.drupal.org/node/3044173
+ * @see \Drupal\Core\Test\UserAgent::validate()
  */
-function drupal_valid_test_ua($new_prefix = NULL) {
-  static $test_prefix;
-
-  if (isset($new_prefix)) {
-    $test_prefix = $new_prefix;
-  }
-  if (isset($test_prefix)) {
-    return $test_prefix;
-  }
-  // Unless the below User-Agent and HMAC validation succeeds, we are not in
-  // a test environment.
-  $test_prefix = FALSE;
-
-  // A valid Simpletest request will contain a hashed and salted authentication
-  // code. Check if this code is present in a cookie or custom user agent
-  // string.
-  $http_user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : NULL;
-  $user_agent = isset($_COOKIE['SIMPLETEST_USER_AGENT']) ? $_COOKIE['SIMPLETEST_USER_AGENT'] : $http_user_agent;
-  if (isset($user_agent) && preg_match("/^simple(\w+\d+):(.+):(.+):(.+)$/", $user_agent, $matches)) {
-    list(, $prefix, $time, $salt, $hmac) = $matches;
-    $check_string = $prefix . ':' . $time . ':' . $salt;
-    // Read the hash salt prepared by drupal_generate_test_ua().
-    // This function is called before settings.php is read and Drupal's error
-    // handlers are set up. While Drupal's error handling may be properly
-    // configured on production sites, the server's PHP error_reporting may not.
-    // Ensure that no information leaks on production sites.
-    $test_db = new TestDatabase($prefix);
-    $key_file = DRUPAL_ROOT . '/' . $test_db->getTestSitePath() . '/.htkey';
-    if (!is_readable($key_file)) {
-      header($_SERVER['SERVER_PROTOCOL'] . ' 403 Forbidden');
-      exit;
+function drupal_valid_test_ua($new_prefix = NULL, Request $request = NULL) {
+  if (!$request) {
+    try {
+      $request = \Drupal::request();
     }
-    $private_key = file_get_contents($key_file);
-    // The file properties add more entropy not easily accessible to others.
-    $key = $private_key . filectime(__FILE__) . fileinode(__FILE__);
-    $time_diff = REQUEST_TIME - $time;
-    $test_hmac = Crypt::hmacBase64($check_string, $key);
-    // Since we are making a local request a 600 second time window is allowed,
-    // and the HMAC must match.
-    if ($time_diff >= 0 && $time_diff <= 600 && hash_equals($test_hmac, $hmac)) {
-      $test_prefix = $prefix;
-    }
-    else {
-      header($_SERVER['SERVER_PROTOCOL'] . ' 403 Forbidden (SIMPLETEST_USER_AGENT invalid)');
-      exit;
+    catch (ContainerNotInitializedException $e) {
+      $request = Request::createFromGlobals();
     }
   }
-  return $test_prefix;
+
+  return UserAgent::validate($request, $new_prefix);
 }
 
 /**
  * Generates a user agent string with a HMAC and timestamp for simpletest.
  */
-function drupal_generate_test_ua($prefix) {
-  static $key, $last_prefix;
-
-  if (!isset($key) || $last_prefix != $prefix) {
-    $last_prefix = $prefix;
-    $test_db = new TestDatabase($prefix);
-    $key_file = DRUPAL_ROOT . '/' . $test_db->getTestSitePath() . '/.htkey';
-    // When issuing an outbound HTTP client request from within an inbound test
-    // request, then the outbound request has to use the same User-Agent header
-    // as the inbound request. A newly generated private key for the same test
-    // prefix would invalidate all subsequent inbound requests.
-    // @see \Drupal\Core\Test\HttpClientMiddleware\TestHttpClientMiddleware
-    if (DRUPAL_TEST_IN_CHILD_SITE && $parent_prefix = drupal_valid_test_ua()) {
-      if ($parent_prefix != $prefix) {
-        throw new \RuntimeException("Malformed User-Agent: Expected '$parent_prefix' but got '$prefix'.");
-      }
-      // If the file is not readable, a PHP warning is expected in this case.
-      $private_key = file_get_contents($key_file);
+function drupal_generate_test_ua($prefix, Request $request = NULL) {
+  @trigger_error('drupal_generate_test_ua() is deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use \Drupal\Core\Test\UserAgent::generate(). See https://www.drupal.org/node/3044173', E_USER_DEPRECATED);
+  if (!$request) {
+    try {
+      $request = \Drupal::request();
     }
-    else {
-      // Generate and save a new hash salt for a test run.
-      // Consumed by drupal_valid_test_ua() before settings.php is loaded.
-      $private_key = Crypt::randomBytesBase64(55);
-      file_put_contents($key_file, $private_key);
+    catch (ContainerNotInitializedException $e) {
+      $request = Request::createFromGlobals();
     }
-    // The file properties add more entropy not easily accessible to others.
-    $key = $private_key . filectime(__FILE__) . fileinode(__FILE__);
   }
-  // Generate a moderately secure HMAC based on the database credentials.
-  $salt = uniqid('', TRUE);
-  $check_string = $prefix . ':' . time() . ':' . $salt;
-  return 'simple' . $check_string . ':' . Crypt::hmacBase64($check_string, $key);
+  return UserAgent::generate($request, $prefix);
 }
 
 /**
diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc
index c75da59c7a..d0049bbb6c 100644
--- a/core/includes/install.core.inc
+++ b/core/includes/install.core.inc
@@ -29,6 +29,7 @@
 use Drupal\Core\StackMiddleware\ReverseProxyMiddleware;
 use Drupal\Core\Extension\ExtensionDiscovery;
 use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Test\UserAgent;
 use Drupal\Core\Url;
 use Drupal\language\Entity\ConfigurableLanguage;
 use Symfony\Cmf\Component\Routing\RouteObjectInterface;
@@ -321,11 +322,11 @@ function install_begin_request($class_loader, &$install_state) {
   // running tests. However, for security reasons, it is imperative that no
   // installation be permitted using such a prefix.
   $user_agent = $request->cookies->get('SIMPLETEST_USER_AGENT') ?: $request->server->get('HTTP_USER_AGENT');
-  if ($install_state['interactive'] && strpos($user_agent, 'simpletest') !== FALSE && !drupal_valid_test_ua()) {
+  if ($install_state['interactive'] && strpos($user_agent, 'simpletest') !== FALSE && !UserAgent::validate($request)) {
     header($request->server->get('SERVER_PROTOCOL') . ' 403 Forbidden');
     exit;
   }
-  if ($install_state['interactive'] && drupal_valid_test_ua()) {
+  if ($install_state['interactive'] && UserAgent::validate($request)) {
     // Set the default timezone. While this doesn't cause any tests to fail, PHP
     // complains if 'date.timezone' is not set in php.ini. The Australia/Sydney
     // timezone is chosen so all tests are run using an edge case scenario
diff --git a/core/lib/Drupal/Core/CoreServiceProvider.php b/core/lib/Drupal/Core/CoreServiceProvider.php
index e07046ae0a..11cde3ce5f 100644
--- a/core/lib/Drupal/Core/CoreServiceProvider.php
+++ b/core/lib/Drupal/Core/CoreServiceProvider.php
@@ -26,7 +26,9 @@
 use Drupal\Core\Plugin\PluginManagerPass;
 use Drupal\Core\Render\MainContent\MainContentRenderersPass;
 use Drupal\Core\Site\Settings;
+use Drupal\Core\Test\UserAgent;
 use Symfony\Component\DependencyInjection\Compiler\PassConfig;
+use Symfony\Component\HttpFoundation\Request;
 
 /**
  * ServiceProvider class for mandatory core services.
@@ -126,7 +128,7 @@ public function alter(ContainerBuilder $container) {
    */
   protected function registerTest(ContainerBuilder $container) {
     // Do nothing if we are not in a test environment.
-    if (!drupal_valid_test_ua()) {
+    if (!UserAgent::validate(Request::createFromGlobals())) {
       return;
     }
     // Add the HTTP request middleware to Guzzle.
diff --git a/core/lib/Drupal/Core/DrupalKernel.php b/core/lib/Drupal/Core/DrupalKernel.php
index 25a0f4e960..69ba82196b 100644
--- a/core/lib/Drupal/Core/DrupalKernel.php
+++ b/core/lib/Drupal/Core/DrupalKernel.php
@@ -23,6 +23,7 @@
 use Drupal\Core\Security\RequestSanitizer;
 use Drupal\Core\Site\Settings;
 use Drupal\Core\Test\TestDatabase;
+use Drupal\Core\Test\UserAgent;
 use Symfony\Cmf\Component\Routing\RouteObjectInterface;
 use Symfony\Component\ClassLoader\ApcClassLoader;
 use Symfony\Component\ClassLoader\WinCacheClassLoader;
@@ -381,7 +382,7 @@ public static function findSitePath(Request $request, $require_settings = TRUE,
     }
 
     // Check for a simpletest override.
-    if ($test_prefix = drupal_valid_test_ua()) {
+    if ($test_prefix = UserAgent::validate($request)) {
       $test_db = new TestDatabase($test_prefix);
       return $test_db->getTestSitePath();
     }
@@ -1019,7 +1020,7 @@ public static function bootEnvironment($app_root = NULL) {
 
     // Indicate that code is operating in a test child site.
     if (!defined('DRUPAL_TEST_IN_CHILD_SITE')) {
-      if ($test_prefix = drupal_valid_test_ua()) {
+      if ($test_prefix = UserAgent::validate(Request::createFromGlobals())) {
         $test_db = new TestDatabase($test_prefix);
         // Only code that interfaces directly with tests should rely on this
         // constant; e.g., the error/exception handler conditionally adds further
diff --git a/core/lib/Drupal/Core/Extension/ExtensionDiscovery.php b/core/lib/Drupal/Core/Extension/ExtensionDiscovery.php
index 717afc3be5..1a589f0dfb 100644
--- a/core/lib/Drupal/Core/Extension/ExtensionDiscovery.php
+++ b/core/lib/Drupal/Core/Extension/ExtensionDiscovery.php
@@ -6,6 +6,7 @@
 use Drupal\Core\DrupalKernel;
 use Drupal\Core\Extension\Discovery\RecursiveExtensionFilterIterator;
 use Drupal\Core\Site\Settings;
+use Drupal\Core\Test\UserAgent;
 use Symfony\Component\HttpFoundation\Request;
 
 /**
@@ -195,7 +196,7 @@ public function scan($type, $include_tests = NULL) {
     // Test extensions can also be included for debugging purposes by setting a
     // variable in settings.php.
     if (!isset($include_tests)) {
-      $include_tests = Settings::get('extension_discovery_scan_tests') || drupal_valid_test_ua();
+      $include_tests = Settings::get('extension_discovery_scan_tests') || UserAgent::validate(Request::createFromGlobals());
     }
 
     $files = [];
@@ -232,7 +233,7 @@ public function setProfileDirectoriesFromSettings() {
     // For SimpleTest to be able to test modules packaged together with a
     // distribution we need to include the profile of the parent site (in
     // which test runs are triggered).
-    if (drupal_valid_test_ua() && !drupal_installation_attempted()) {
+    if (UserAgent::validate(Request::createFromGlobals()) && !drupal_installation_attempted()) {
       $testing_profile = \Drupal::config('simpletest.settings')->get('parent_profile');
       if ($testing_profile && $testing_profile != $profile) {
         $this->profileDirectories[] = drupal_get_path('profile', $testing_profile);
diff --git a/core/lib/Drupal/Core/Installer/Form/SelectProfileForm.php b/core/lib/Drupal/Core/Installer/Form/SelectProfileForm.php
index b0b72f6a99..1c7c7f83ef 100644
--- a/core/lib/Drupal/Core/Installer/Form/SelectProfileForm.php
+++ b/core/lib/Drupal/Core/Installer/Form/SelectProfileForm.php
@@ -6,6 +6,8 @@
 use Drupal\Core\Form\FormBase;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Site\Settings;
+use Drupal\Core\Test\UserAgent;
+use Symfony\Component\HttpFoundation\Request;
 
 /**
  * Provides the profile selection form.
@@ -41,7 +43,7 @@ public function buildForm(array $form, FormStateInterface $form_state, $install_
       $details = install_profile_info($profile->getName());
       // Don't show hidden profiles. This is used by to hide the testing profile,
       // which only exists to speed up test runs.
-      if ($details['hidden'] === TRUE && !drupal_valid_test_ua()) {
+      if ($details['hidden'] === TRUE && !UserAgent::validate(Request::createFromGlobals())) {
         continue;
       }
       $profiles[$profile->getName()] = $details;
diff --git a/core/lib/Drupal/Core/Session/SessionConfiguration.php b/core/lib/Drupal/Core/Session/SessionConfiguration.php
index bbdde7cb5d..5f28cf9a28 100644
--- a/core/lib/Drupal/Core/Session/SessionConfiguration.php
+++ b/core/lib/Drupal/Core/Session/SessionConfiguration.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\Core\Session;
 
+use Drupal\Core\Test\UserAgent;
 use Symfony\Component\HttpFoundation\Request;
 
 /**
@@ -83,7 +84,7 @@ protected function getName(Request $request) {
    *   The session name without the prefix (SESS/SSESS).
    */
   protected function getUnprefixedName(Request $request) {
-    if ($test_prefix = $this->drupalValidTestUa()) {
+    if ($test_prefix = $this->drupalValidTestUa($request)) {
       $session_name = $test_prefix;
     }
     elseif (isset($this->options['cookie_domain'])) {
@@ -139,15 +140,18 @@ protected function getCookieDomain(Request $request) {
   }
 
   /**
-   * Wraps drupal_valid_test_ua().
+   * Wraps \Drupal\Core\Test\UserAgent::validate().
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request.
    *
    * @return string|false
    *   Either the simpletest prefix (the string "simpletest" followed by any
    *   number of digits) or FALSE if the user agent does not contain a valid
    *   HMAC and timestamp.
    */
-  protected function drupalValidTestUa() {
-    return drupal_valid_test_ua();
+  protected function drupalValidTestUa(Request $request) {
+    return UserAgent::validate($request);
   }
 
 }
diff --git a/core/lib/Drupal/Core/Test/FunctionalTestSetupTrait.php b/core/lib/Drupal/Core/Test/FunctionalTestSetupTrait.php
index 3f8a74326a..b601de9ce2 100644
--- a/core/lib/Drupal/Core/Test/FunctionalTestSetupTrait.php
+++ b/core/lib/Drupal/Core/Test/FunctionalTestSetupTrait.php
@@ -307,8 +307,9 @@ protected function initSettings() {
     $this->configDirectories['sync'] = Settings::get('config_sync_directory');
 
     // After writing settings.php, the installer removes write permissions
-    // from the site directory. To allow drupal_generate_test_ua() to write
-    // a file containing the private key for drupal_valid_test_ua(), the site
+    // from the site directory. To allow
+    // \Drupal\Core\Test\UserAgent::generate() to write a file containing
+    // the private key for \Drupal\Core\Test\UserAgent::validate(), the site
     // directory has to be writable.
     // TestBase::restoreEnvironment() will delete the entire site directory.
     // Not using File API; a potential error must trigger a PHP warning.
@@ -627,7 +628,7 @@ protected function prepareEnvironment() {
 
     // After preparing the environment and changing the database prefix, we are
     // in a valid test environment.
-    drupal_valid_test_ua($this->databasePrefix);
+    UserAgent::validate($request, $this->databasePrefix);
 
     // Reset settings.
     new Settings([
diff --git a/core/lib/Drupal/Core/Test/HttpClientMiddleware/TestHttpClientMiddleware.php b/core/lib/Drupal/Core/Test/HttpClientMiddleware/TestHttpClientMiddleware.php
index ecb629316e..1f298138a1 100644
--- a/core/lib/Drupal/Core/Test/HttpClientMiddleware/TestHttpClientMiddleware.php
+++ b/core/lib/Drupal/Core/Test/HttpClientMiddleware/TestHttpClientMiddleware.php
@@ -2,9 +2,11 @@
 
 namespace Drupal\Core\Test\HttpClientMiddleware;
 
+use Drupal\Core\Test\UserAgent;
 use Drupal\Core\Utility\Error;
 use Psr\Http\Message\RequestInterface;
 use Psr\Http\Message\ResponseInterface;
+use Symfony\Component\HttpFoundation\Request;
 
 /**
  * Overrides the User-Agent HTTP header for outbound HTTP requests.
@@ -25,12 +27,13 @@ public function __invoke() {
     // prefix were stored statically in a file or database variable.
     return function ($handler) {
       return function (RequestInterface $request, array $options) use ($handler) {
-        if ($test_prefix = drupal_valid_test_ua()) {
-          $request = $request->withHeader('User-Agent', drupal_generate_test_ua($test_prefix));
+        $symphony_request = Request::createFromGlobals();
+        if ($test_prefix = UserAgent::validate($symphony_request)) {
+          $request = $request->withHeader('User-Agent', UserAgent::generate($symphony_request, $test_prefix));
         }
         return $handler($request, $options)
-          ->then(function (ResponseInterface $response) use ($request) {
-            if (!drupal_valid_test_ua()) {
+          ->then(function (ResponseInterface $response) use ($request, $symphony_request) {
+            if (!UserAgent::validate($symphony_request)) {
               return $response;
             }
             $headers = $response->getHeaders();
diff --git a/core/lib/Drupal/Core/Test/TestKernel.php b/core/lib/Drupal/Core/Test/TestKernel.php
index f7eed629d4..7c8cff8217 100644
--- a/core/lib/Drupal/Core/Test/TestKernel.php
+++ b/core/lib/Drupal/Core/Test/TestKernel.php
@@ -3,6 +3,7 @@
 namespace Drupal\Core\Test;
 
 use Drupal\Core\DrupalKernel;
+use Symfony\Component\HttpFoundation\Request;
 
 /**
  * Kernel to mock requests to test simpletest.
@@ -17,7 +18,7 @@ public function __construct($environment, $class_loader, $allow_dumping = TRUE)
     require_once __DIR__ . '/../../../../includes/bootstrap.inc';
 
     // Exit if we should be in a test environment but aren't.
-    if (!drupal_valid_test_ua()) {
+    if (!UserAgent::validate(Request::createFromGlobals())) {
       header($_SERVER['SERVER_PROTOCOL'] . ' 403 Forbidden');
       exit;
     }
diff --git a/core/lib/Drupal/Core/Test/TestSetupTrait.php b/core/lib/Drupal/Core/Test/TestSetupTrait.php
index 0625691022..55f029f833 100644
--- a/core/lib/Drupal/Core/Test/TestSetupTrait.php
+++ b/core/lib/Drupal/Core/Test/TestSetupTrait.php
@@ -139,7 +139,7 @@ public static function getDatabaseConnection() {
    * @see \Drupal\Tests\BrowserTestBase::prepareEnvironment()
    * @see \Drupal\simpletest\WebTestBase::curlInitialize()
    * @see \Drupal\simpletest\TestBase::prepareEnvironment()
-   * @see drupal_valid_test_ua()
+   * @see \Drupal\Core\Test\UserAgent::validate()
    */
   protected function prepareDatabasePrefix() {
     $test_db = new TestDatabase();
diff --git a/core/lib/Drupal/Core/Test/UserAgent.php b/core/lib/Drupal/Core/Test/UserAgent.php
new file mode 100755
index 0000000000..80666ecb54
--- /dev/null
+++ b/core/lib/Drupal/Core/Test/UserAgent.php
@@ -0,0 +1,142 @@
+<?php
+
+namespace Drupal\Core\Test;
+
+use Drupal\Component\Utility\Crypt;
+use Drupal\Core\DependencyInjection\ContainerNotInitializedException;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Generates and validates test user agents.
+ *
+ * @see https://www.drupal.org/project/drupal/issues/2750461
+ *
+ * @package Drupal\Core\Test
+ */
+class UserAgent {
+
+  /**
+   * Test prefix.
+   *
+   * @var string
+   */
+  protected static $testPrefix;
+
+  /**
+   * Test last prefix.
+   *
+   * @var string
+   */
+  protected static $testLastPrefix;
+
+  /**
+   * Test key.
+   *
+   * @var string
+   */
+  protected static $testKey;
+
+  /**
+   * Returns the test prefix if this is an internal request from SimpleTest.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   Request instance.
+   * @param string|null $new_prefix
+   *   Internal use only. A new prefix to be stored.
+   *
+   * @return string|false
+   *   Either the simpletest prefix (the string "simpletest" followed by any
+   *   number of digits) or FALSE if the user agent does not contain a valid
+   *   HMAC and timestamp.
+   */
+  public static function validate(Request $request, $new_prefix = NULL) {
+    if (isset($new_prefix)) {
+      static::$testPrefix = $new_prefix;
+    }
+    if (isset(static::$testPrefix)) {
+      return static::$testPrefix;
+    }
+    // Unless the below User-Agent and HMAC validation succeeds, we are not in
+    // a test environment.
+    static::$testPrefix = FALSE;
+
+    // A valid browser test request will contain a hashed and salted
+    // authentication code. Check if this code is present in a cookie or custom
+    // user agent string.
+    $http_user_agent = $request->server->get('HTTP_USER_AGENT');
+    $user_agent = $request->cookies->get('SIMPLETEST_USER_AGENT', $http_user_agent);
+    if (isset($user_agent) && preg_match("/^simple(\w+\d+):(.+):(.+):(.+)$/", $user_agent, $matches)) {
+      list(, $prefix, $time, $salt, $hmac) = $matches;
+      $check_string = $prefix . ':' . $time . ':' . $salt;
+      // Read the hash salt prepared by UserAgent::generate().
+      // This function is called before settings.php is read and Drupal's error
+      // handlers are set up. While Drupal's error handling may be properly
+      // configured on production sites, the server's PHP error_reporting may
+      // not. Ensure that no information leaks on production sites.
+      $test_db = new TestDatabase($prefix);
+      $key_file = DRUPAL_ROOT . '/' . $test_db->getTestSitePath() . '/.htkey';
+      if (!is_readable($key_file)) {
+        header($request->server->get('SERVER_PROTOCOL') . ' 403 Forbidden');
+        exit;
+      }
+      $private_key = file_get_contents($key_file);
+      // The file properties add more entropy not easily accessible to others.
+      $key = $private_key . filectime(__FILE__) . fileinode(__FILE__);
+      $time_diff = REQUEST_TIME - $time;
+      $test_hmac = Crypt::hmacBase64($check_string, $key);
+      // Since we are making a local request a 600 second time window is
+      // allowed, and the HMAC must match.
+      if ($time_diff >= 0 && $time_diff <= 600 && hash_equals($test_hmac, $hmac)) {
+        static::$testPrefix = $prefix;
+      }
+      else {
+        header($request->server->get('SERVER_PROTOCOL') . ' 403 Forbidden (SIMPLETEST_USER_AGENT invalid)');
+        exit;
+      }
+    }
+    return static::$testPrefix;
+  }
+
+  /**
+   * Generates a user agent string with a HMAC and timestamp for simpletest.
+   *
+   * @param string $prefix
+   *   Prefix.
+   *
+   * @return string
+   *   User agent string.
+   */
+  public static function generate(Request $request, $prefix) {
+    if (!isset(static::$testKey) || static::$testLastPrefix != $prefix) {
+      static::$testLastPrefix = $prefix;
+      $test_db = new TestDatabase($prefix);
+      $key_file = DRUPAL_ROOT . '/' . $test_db->getTestSitePath() . '/.htkey';
+      // When issuing an outbound HTTP client request from within an inbound
+      // test request, then the outbound request has to use the same User-Agent
+      // header as the inbound request. A newly generated private key for the
+      // same test prefix would invalidate all subsequent inbound requests.
+      // @see \Drupal\Core\Test\HttpClientMiddleware\TestHttpClientMiddleware
+      if (defined('DRUPAL_TEST_IN_CHILD_SITE') && DRUPAL_TEST_IN_CHILD_SITE && $parent_prefix = static::validate($request)) {
+        if ($parent_prefix != $prefix) {
+          throw new \RuntimeException("Malformed User-Agent: Expected '$parent_prefix' but got '$prefix'.");
+        }
+        // If the file is not readable, a PHP warning is expected in this case.
+        $private_key = file_get_contents($key_file);
+      }
+      else {
+        // Generate and save a new hash salt for a test run.
+        // Consumed by \Drupal\Core\Test\UserAgent::validate() before
+        // settings.php is loaded.
+        $private_key = Crypt::randomBytesBase64(55);
+        file_put_contents($key_file, $private_key);
+      }
+      // The file properties add more entropy not easily accessible to others.
+      static::$testKey = $private_key . filectime(__FILE__) . fileinode(__FILE__);
+    }
+    // Generate a moderately secure HMAC based on the database credentials.
+    $salt = uniqid('', TRUE);
+    $check_string = $prefix . ':' . time() . ':' . $salt;
+    return 'simple' . $check_string . ':' . Crypt::hmacBase64($check_string, static::$testKey);
+  }
+
+}
diff --git a/core/modules/page_cache/tests/src/Functional/PageCacheTest.php b/core/modules/page_cache/tests/src/Functional/PageCacheTest.php
index 88c5311fdd..73c0948829 100644
--- a/core/modules/page_cache/tests/src/Functional/PageCacheTest.php
+++ b/core/modules/page_cache/tests/src/Functional/PageCacheTest.php
@@ -4,12 +4,14 @@
 
 use Drupal\Component\Datetime\DateTimePlus;
 use Drupal\Core\Site\Settings;
+use Drupal\Core\Test\UserAgent;
 use Drupal\Core\Url;
 use Drupal\entity_test\Entity\EntityTest;
 use Drupal\Core\Cache\Cache;
 use Drupal\Tests\BrowserTestBase;
 use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
 use Drupal\user\RoleInterface;
+use Symfony\Component\HttpFoundation\Request;
 
 /**
  * Enables the page cache and tests it with various HTTP requests.
@@ -624,7 +626,7 @@ protected function getHeaders($url) {
     curl_setopt($ch, CURLOPT_HEADER, TRUE);
     curl_setopt($ch, CURLOPT_NOBODY, TRUE);
     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
-    curl_setopt($ch, CURLOPT_USERAGENT, drupal_generate_test_ua($this->databasePrefix));
+    curl_setopt($ch, CURLOPT_USERAGENT, UserAgent::generate(Request::createFromGlobals(), $this->databasePrefix));
     $output = curl_exec($ch);
     curl_close($ch);
 
diff --git a/core/modules/simpletest/simpletest.install b/core/modules/simpletest/simpletest.install
index 4245027891..c24db4479f 100644
--- a/core/modules/simpletest/simpletest.install
+++ b/core/modules/simpletest/simpletest.install
@@ -8,7 +8,9 @@
 use Drupal\Component\Utility\Environment;
 use Drupal\Core\File\Exception\FileException;
 use Drupal\Core\Test\TestDatabase;
+use Drupal\Core\Test\UserAgent;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpFoundation\Request;
 
 /**
  * Minimum value of PHP memory_limit for SimpleTest.
@@ -89,7 +91,7 @@ function simpletest_uninstall() {
   // Do not clean the environment in case the Simpletest module is uninstalled
   // in a (recursive) test for itself, since simpletest_clean_environment()
   // would also delete the test site of the parent test process.
-  if (!drupal_valid_test_ua()) {
+  if (!UserAgent::validate(Request::createFromGlobals())) {
     simpletest_clean_environment();
   }
   // Delete verbose test output and any other testing framework files.
diff --git a/core/modules/simpletest/src/InstallerTestBase.php b/core/modules/simpletest/src/InstallerTestBase.php
index 703097696d..22db4f9a8a 100644
--- a/core/modules/simpletest/src/InstallerTestBase.php
+++ b/core/modules/simpletest/src/InstallerTestBase.php
@@ -151,8 +151,9 @@ protected function setUp() {
       $this->configDirectories['sync'] = Settings::get('config_sync_directory');
 
       // After writing settings.php, the installer removes write permissions
-      // from the site directory. To allow drupal_generate_test_ua() to write
-      // a file containing the private key for drupal_valid_test_ua(), the site
+      // from the site directory. To allow
+      // \Drupal\Core\Test\UserAgent::generate() to write a file containing
+      // the private key for \Drupal\Core\Test\UserAgent::validate(), the site
       // directory has to be writable.
       // WebTestBase::tearDown() will delete the entire test site directory.
       // Not using File API; a potential error must trigger a PHP warning.
diff --git a/core/modules/simpletest/src/TestBase.php b/core/modules/simpletest/src/TestBase.php
index c3d5495fd0..a2dbc886a9 100644
--- a/core/modules/simpletest/src/TestBase.php
+++ b/core/modules/simpletest/src/TestBase.php
@@ -14,6 +14,7 @@
 use Drupal\Core\Test\TestDatabase;
 use Drupal\Core\Test\TestDiscovery;
 use Drupal\Core\Test\TestSetupTrait;
+use Drupal\Core\Test\UserAgent;
 use Drupal\Core\Utility\Error;
 use Drupal\Tests\AssertHelperTrait as BaseAssertHelperTrait;
 use Drupal\Tests\ConfigTestTrait;
@@ -1031,7 +1032,7 @@ public function run(array $methods = []) {
    * database table prefix that has been generated here.
    *
    * @see WebTestBase::curlInitialize()
-   * @see drupal_valid_test_ua()
+   * @see \Drupal\Core\Test\UserAgent::validate()
    */
   private function prepareDatabasePrefix() {
     $test_db = new TestDatabase();
@@ -1089,7 +1090,7 @@ private function prepareEnvironment() {
     // When running the test runner within a test, back up the original database
     // prefix.
     if (DRUPAL_TEST_IN_CHILD_SITE) {
-      $this->originalPrefix = drupal_valid_test_ua();
+      $this->originalPrefix = UserAgent::validate(\Drupal::request());
     }
 
     // Backup current in-memory configuration.
@@ -1177,7 +1178,7 @@ private function prepareEnvironment() {
 
     // After preparing the environment and changing the database prefix, we are
     // in a valid test environment.
-    drupal_valid_test_ua($this->databasePrefix);
+    UserAgent::validate(\Drupal::request(), $this->databasePrefix);
 
     // Reset settings.
     new Settings([
@@ -1281,11 +1282,12 @@ private function restoreEnvironment() {
     // uses the public stream wrapper to locate the error.log.
     $this->originalContainer->get('stream_wrapper_manager')->register();
 
+    $request = \Drupal::request();
     if (isset($this->originalPrefix)) {
-      drupal_valid_test_ua($this->originalPrefix);
+      UserAgent::validate($request, $this->originalPrefix);
     }
     else {
-      drupal_valid_test_ua(FALSE);
+      UserAgent::validate($request, FALSE);
     }
 
     // Restore original shutdown callbacks.
diff --git a/core/modules/simpletest/src/Tests/SimpleTestBrowserTest.php b/core/modules/simpletest/src/Tests/SimpleTestBrowserTest.php
index 0aeceaf58c..fcf011926a 100644
--- a/core/modules/simpletest/src/Tests/SimpleTestBrowserTest.php
+++ b/core/modules/simpletest/src/Tests/SimpleTestBrowserTest.php
@@ -2,8 +2,10 @@
 
 namespace Drupal\simpletest\Tests;
 
+use Drupal\Core\Test\UserAgent;
 use Drupal\Core\Url;
 use Drupal\simpletest\WebTestBase;
+use Symfony\Component\HttpFoundation\Request;
 
 /**
  * Tests the WebTestBase internal browser.
@@ -70,7 +72,7 @@ public function testInternalBrowser() {
     // Remove the Simpletest private key file so we can test the protection
     // against requests that forge a valid testing user agent to gain access
     // to the installer.
-    // @see drupal_valid_test_ua()
+    // @see \Drupal\Core\Test\UserAgent::validate()
     // Not using File API; a potential error must trigger a PHP warning.
     unlink($this->siteDirectory . '/.htkey');
     $this->drupalGet(Url::fromUri('base:core/install.php', ['external' => TRUE, 'absolute' => TRUE])->toString());
@@ -91,7 +93,7 @@ public function testUserAgentValidation() {
     $https_path = $system_path . '/tests/https.php/user/login';
     // Generate a valid simpletest User-Agent to pass validation.
     $this->assertTrue(preg_match('/test\d+/', $this->databasePrefix, $matches), 'Database prefix contains test prefix.');
-    $test_ua = drupal_generate_test_ua($matches[0]);
+    $test_ua = UserAgent::generate(Request::createFromGlobals(), $matches[0]);
     $this->additionalCurlOptions = [CURLOPT_USERAGENT => $test_ua];
 
     // Test pages only available for testing.
diff --git a/core/modules/simpletest/src/Tests/SimpleTestTest.php b/core/modules/simpletest/src/Tests/SimpleTestTest.php
index ea10bcef71..273c9335c6 100644
--- a/core/modules/simpletest/src/Tests/SimpleTestTest.php
+++ b/core/modules/simpletest/src/Tests/SimpleTestTest.php
@@ -154,10 +154,10 @@ public function testWebTestRunner() {
   public function stubTest() {
     // Ensure the .htkey file exists since this is only created just before a
     // request. This allows the stub test to make requests. The event does not
-    // fire here and drupal_generate_test_ua() can not generate a key for a
-    // test in a test since the prefix has changed.
+    // fire here and \Drupal\Core\Test\UserAgent::generate() can not
+    // generate a key for a test in a test since the prefix has changed.
     // @see \Drupal\Core\Test\HttpClientMiddleware\TestHttpClientMiddleware::onBeforeSendRequest()
-    // @see drupal_generate_test_ua();
+    // @see \Drupal\Core\Test\UserAgent::generate();
     $test_db = new TestDatabase($this->databasePrefix);
     $key_file = DRUPAL_ROOT . '/' . $test_db->getTestSitePath() . '/.htkey';
     $private_key = Crypt::randomBytesBase64(55);
diff --git a/core/modules/simpletest/src/WebTestBase.php b/core/modules/simpletest/src/WebTestBase.php
index 3f0ab2ff13..69b41fa709 100644
--- a/core/modules/simpletest/src/WebTestBase.php
+++ b/core/modules/simpletest/src/WebTestBase.php
@@ -15,6 +15,7 @@
 use Drupal\Core\Test\AssertMailTrait;
 use Drupal\Core\Test\FunctionalTestSetupTrait;
 use Drupal\Core\Test\TestDiscovery;
+use Drupal\Core\Test\UserAgent;
 use Drupal\Core\Url;
 use Drupal\KernelTests\AssertContentTrait as CoreAssertContentTrait;
 use Drupal\system\Tests\Cache\AssertPageCacheContextsAndTagsTrait;
@@ -27,6 +28,7 @@
 use Drupal\Tests\TestFileCreationTrait;
 use Drupal\Tests\user\Traits\UserCreationTrait as BaseUserCreationTrait;
 use Drupal\Tests\XdebugRequestTrait;
+use Symfony\Component\HttpFoundation\Request;
 use Zend\Diactoros\Uri;
 
 /**
@@ -565,7 +567,7 @@ protected function curlInitialize() {
     }
     // We set the user agent header on each request so as to use the current
     // time and a new uniqid.
-    curl_setopt($this->curlHandle, CURLOPT_USERAGENT, drupal_generate_test_ua($this->databasePrefix));
+    curl_setopt($this->curlHandle, CURLOPT_USERAGENT, UserAgent::generate(Request::createFromGlobals(), $this->databasePrefix));
   }
 
   /**
diff --git a/core/modules/update/src/Form/UpdateManagerInstall.php b/core/modules/update/src/Form/UpdateManagerInstall.php
index 67e989de8c..0cced20339 100644
--- a/core/modules/update/src/Form/UpdateManagerInstall.php
+++ b/core/modules/update/src/Form/UpdateManagerInstall.php
@@ -7,8 +7,10 @@
 use Drupal\Core\FileTransfer\Local;
 use Drupal\Core\Form\FormBase;
 use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Test\UserAgent;
 use Drupal\Core\Updater\Updater;
 use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 
 /**
@@ -236,7 +238,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
 
     // This process is inherently difficult to test therefore use a state flag.
     $test_authorize = FALSE;
-    if (drupal_valid_test_ua()) {
+    if (UserAgent::validate(Request::createFromGlobals())) {
       $test_authorize = \Drupal::state()->get('test_uploaders_via_prompt', FALSE);
     }
     // If the owner of the directory we extracted is the same as the owner of
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Core/Session/SessionTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Core/Session/SessionTest.php
index f4a1800382..2a7b8a6f4f 100644
--- a/core/tests/Drupal/FunctionalJavascriptTests/Core/Session/SessionTest.php
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/Session/SessionTest.php
@@ -39,9 +39,9 @@ protected function setUp() {
   /**
    * Tests that the session doesn't expire.
    *
-   * Makes sure that drupal_valid_test_ua() works for multiple requests
-   * performed by the Mink browser. The SIMPLETEST_USER_AGENT cookie must always
-   * be valid.
+   * Makes sure that \Drupal\Core\Test\UserAgent::validate() works for multiple
+   * requests performed by the Mink browser. The SIMPLETEST_USER_AGENT cookie
+   * must always be valid.
    */
   public function testSessionExpiration() {
     // Visit the front page and click the link back to the front page a large
diff --git a/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php b/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php
index 1a0f19e971..7e36ca7a34 100644
--- a/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php
+++ b/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php
@@ -3,6 +3,7 @@
 namespace Drupal\FunctionalTests\Bootstrap;
 
 use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Core\Test\UserAgent;
 use Drupal\Tests\BrowserTestBase;
 
 /**
@@ -351,7 +352,7 @@ protected function drupalGet($path, array $extra_options = [], array $headers =
     curl_setopt($ch, CURLOPT_URL, $url);
     curl_setopt($ch, CURLOPT_HEADER, FALSE);
     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
-    curl_setopt($ch, CURLOPT_USERAGENT, drupal_generate_test_ua($this->databasePrefix));
+    curl_setopt($ch, CURLOPT_USERAGENT, UserAgent::generate(\Drupal::request(), $this->databasePrefix));
     $this->response = curl_exec($ch);
     $this->info = curl_getinfo($ch);
     curl_close($ch);
diff --git a/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php b/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php
index 3a94bddd76..4050153657 100644
--- a/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php
+++ b/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php
@@ -702,7 +702,7 @@ public function testHtkey() {
     // Remove the Simpletest private key file so we can test the protection
     // against requests that forge a valid testing user agent to gain access
     // to the installer.
-    // @see drupal_valid_test_ua()
+    // @see \Drupal\Core\Test\UserAgent::validate()
     // Not using File API; a potential error must trigger a PHP warning.
     $install_url = Url::fromUri('base:core/install.php', ['external' => TRUE, 'absolute' => TRUE])->toString();
     $this->drupalGet($install_url);
diff --git a/core/tests/Drupal/FunctionalTests/BrowserTestBaseUserAgentTest.php b/core/tests/Drupal/FunctionalTests/BrowserTestBaseUserAgentTest.php
index 8468fd5af8..4b49f44a06 100644
--- a/core/tests/Drupal/FunctionalTests/BrowserTestBaseUserAgentTest.php
+++ b/core/tests/Drupal/FunctionalTests/BrowserTestBaseUserAgentTest.php
@@ -2,7 +2,9 @@
 
 namespace Drupal\FunctionalTests;
 
+use Drupal\Core\Test\UserAgent;
 use Drupal\Tests\BrowserTestBase;
+use Symfony\Component\HttpFoundation\Request;
 
 /**
  * Tests BrowserTestBase functionality.
@@ -28,7 +30,7 @@ public function testUserAgentValidation() {
     $https_path = $system_path . '/tests/https.php/user/login';
     // Generate a valid simpletest User-Agent to pass validation.
     $this->assertTrue(preg_match('/test\d+/', $this->databasePrefix, $matches), 'Database prefix contains test prefix.');
-    $this->agent = drupal_generate_test_ua($matches[0]);
+    $this->agent = UserAgent::generate(Request::createFromGlobals(), $matches[0]);
 
     // Test pages only available for testing.
     $this->drupalGet($http_path);
@@ -61,7 +63,7 @@ protected function prepareRequest() {
       $session->setCookie('SIMPLETEST_USER_AGENT', $this->agent);
     }
     else {
-      $session->setCookie('SIMPLETEST_USER_AGENT', drupal_generate_test_ua($this->databasePrefix));
+      $session->setCookie('SIMPLETEST_USER_AGENT', UserAgent::generate(Request::createFromGlobals(), $this->databasePrefix));
     }
   }
 
diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerTestBase.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerTestBase.php
index eaccc1bdf2..594afaed3b 100644
--- a/core/tests/Drupal/FunctionalTests/Installer/InstallerTestBase.php
+++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerTestBase.php
@@ -174,8 +174,9 @@ protected function setUp() {
       $this->configDirectories['sync'] = Settings::get('config_sync_directory');
 
       // After writing settings.php, the installer removes write permissions
-      // from the site directory. To allow drupal_generate_test_ua() to write
-      // a file containing the private key for drupal_valid_test_ua(), the site
+      // from the site directory. To allow
+      // \Drupal\Core\Test\UserAgent::generate() to write a file containing
+      // the private key for \Drupal\Core\Test\UserAgent::validate(), the site
       // directory has to be writable.
       // BrowserTestBase::tearDown() will delete the entire test site directory.
       // Not using File API; a potential error must trigger a PHP warning.
diff --git a/core/tests/Drupal/FunctionalTests/UserAgentLegacyTest.php b/core/tests/Drupal/FunctionalTests/UserAgentLegacyTest.php
new file mode 100644
index 0000000000..5f4b67a99b
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/UserAgentLegacyTest.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Drupal\FunctionalTests;
+
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Test legacy UA functions.
+ *
+ * @group Test
+ * @group legacy
+ */
+class UserAgentLegacyTest extends BrowserTestBase {
+
+  /**
+   * Test drupal_valid_test_ua() and drupal_generate_test_ua() functions.
+   *
+   * @expectedDeprecation drupal_valid_test_ua() is deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use \Drupal\Core\Test\UserAgent::validate(). See https://www.drupal.org/node/3044173
+   * @expectedDeprecation drupal_generate_test_ua() is deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use \Drupal\Core\Test\UserAgent::generate(). See https://www.drupal.org/node/3044173
+   */
+  public function testDrupalTestUa() {
+    $test_prefix = drupal_generate_test_ua(drupal_valid_test_ua());
+    $this->assertNotEmpty($test_prefix);
+    $this->assertNotFalse(drupal_valid_test_ua($test_prefix));
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Test/UserAgentTest.php b/core/tests/Drupal/KernelTests/Core/Test/UserAgentTest.php
new file mode 100755
index 0000000000..7413997434
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Test/UserAgentTest.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\KernelTests\Core\Test;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Core\Test\UserAgent;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Tests \Drupal\Tests\UserAgent.
+ *
+ * @group Test
+ * @group FunctionalTests
+ *
+ * @coversDefaultClass \Drupal\Core\Test\UserAgent
+ */
+class UserAgentTest extends KernelTestBase {
+
+  /**
+   * Test that ::validate return expected string.
+   *
+   * @covers ::validate
+   */
+  public function testValidate() {
+    $this->assertContains('test', UserAgent::validate(Request::createFromGlobals()));
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/KernelTestBase.php b/core/tests/Drupal/KernelTests/KernelTestBase.php
index e2d1c17f3b..7beb207f9f 100644
--- a/core/tests/Drupal/KernelTests/KernelTestBase.php
+++ b/core/tests/Drupal/KernelTests/KernelTestBase.php
@@ -17,6 +17,7 @@
 use Drupal\Core\Language\Language;
 use Drupal\Core\Site\Settings;
 use Drupal\Core\Test\TestDatabase;
+use Drupal\Core\Test\UserAgent;
 use Drupal\Tests\AssertHelperTrait;
 use Drupal\Tests\ConfigTestTrait;
 use Drupal\Tests\PhpunitCompatibilityTrait;
@@ -260,11 +261,12 @@ protected function bootEnvironment() {
     $test_db = new TestDatabase();
     $this->siteDirectory = $test_db->getTestSitePath();
 
-    // Ensure that all code that relies on drupal_valid_test_ua() can still be
+    // Ensure that all code that relies on
+    // \Drupal\Core\Test\UserAgent::validate() can still be
     // safely executed. This primarily affects the (test) site directory
     // resolution (used by e.g. LocalStream and PhpStorage).
     $this->databasePrefix = $test_db->getDatabasePrefix();
-    drupal_valid_test_ua($this->databasePrefix);
+    UserAgent::validate(Request::createFromGlobals(), $this->databasePrefix);
 
     $settings = [
       'hash_salt' => get_class($this),
diff --git a/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php b/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php
index d020304018..ec139e2ae1 100644
--- a/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php
+++ b/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php
@@ -6,6 +6,7 @@
 use Drupal\Core\Test\FunctionalTestSetupTrait;
 use Drupal\Core\Test\TestDatabase;
 use Drupal\Core\Test\TestSetupTrait;
+use Drupal\Core\Test\UserAgent;
 use Drupal\TestSite\TestSetupInterface;
 use Drupal\Tests\RandomGeneratorTrait;
 use Symfony\Component\Console\Command\Command;
@@ -13,6 +14,7 @@
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\HttpFoundation\Request;
 
 /**
  * Command to create a test Drupal site.
@@ -97,7 +99,7 @@ protected function execute(InputInterface $input, OutputInterface $output) {
     // Manage site fixture.
     $this->setup($input->getOption('install-profile'), $class_name, $input->getOption('langcode'));
 
-    $user_agent = drupal_generate_test_ua($this->databasePrefix);
+    $user_agent = UserAgent::generate(Request::createFromGlobals(), $this->databasePrefix);
     if ($input->getOption('json')) {
       $output->writeln(json_encode([
         'db_prefix' => $this->databasePrefix,
diff --git a/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php b/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php
index 830eb0d5ce..48dea9deec 100644
--- a/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php
+++ b/core/tests/Drupal/Tests/Core/Command/QuickStartTest.php
@@ -3,10 +3,12 @@
 namespace Drupal\Tests\Core\Command;
 
 use Drupal\Core\Test\TestDatabase;
+use Drupal\Core\Test\UserAgent;
 use Drupal\Tests\BrowserTestBase;
 use GuzzleHttp\Client;
 use GuzzleHttp\Cookie\CookieJar;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\Process\PhpExecutableFinder;
 use Symfony\Component\Process\Process;
 
@@ -125,7 +127,7 @@ public function testQuickStartCommand() {
     define('DRUPAL_TEST_IN_CHILD_SITE', FALSE);
     chmod($this->testDb->getTestSitePath(), 0755);
     $cookieJar = CookieJar::fromArray([
-      'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()),
+      'SIMPLETEST_USER_AGENT' => UserAgent::generate(Request::createFromGlobals(), $this->testDb->getDatabasePrefix()),
     ], '127.0.0.1');
 
     $response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
@@ -225,7 +227,7 @@ public function testQuickStartInstallAndServerCommands() {
     define('DRUPAL_TEST_IN_CHILD_SITE', FALSE);
     chmod($this->testDb->getTestSitePath(), 0755);
     $cookieJar = CookieJar::fromArray([
-      'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()),
+      'SIMPLETEST_USER_AGENT' => UserAgent::generate(Request::createFromGlobals(), $this->testDb->getDatabasePrefix()),
     ], '127.0.0.1');
 
     $response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
diff --git a/core/tests/Drupal/Tests/Core/DrupalKernel/DrupalKernelTest.php b/core/tests/Drupal/Tests/Core/DrupalKernel/DrupalKernelTest.php
index 1151382b1d..0411bd6f46 100644
--- a/core/tests/Drupal/Tests/Core/DrupalKernel/DrupalKernelTest.php
+++ b/core/tests/Drupal/Tests/Core/DrupalKernel/DrupalKernelTest.php
@@ -240,14 +240,3 @@ public function findFile() {
 
   }
 }
-
-namespace {
-
-  if (!function_exists('drupal_valid_test_ua')) {
-
-    function drupal_valid_test_ua($new_prefix = NULL) {
-      return FALSE;
-    }
-
-  }
-}
diff --git a/core/tests/Drupal/Tests/UiHelperTrait.php b/core/tests/Drupal/Tests/UiHelperTrait.php
index 4b1a421258..dec8c762d4 100644
--- a/core/tests/Drupal/Tests/UiHelperTrait.php
+++ b/core/tests/Drupal/Tests/UiHelperTrait.php
@@ -9,6 +9,7 @@
 use Drupal\Core\Session\AccountInterface;
 use Drupal\Core\Session\AnonymousUserSession;
 use Drupal\Core\Test\RefreshVariablesTrait;
+use Drupal\Core\Test\UserAgent;
 use Drupal\Core\Url;
 
 /**
@@ -424,13 +425,11 @@ protected function getAbsoluteUrl($path) {
    * Prepare for a request to testing site.
    *
    * The testing site is protected via a SIMPLETEST_USER_AGENT cookie that is
-   * checked by drupal_valid_test_ua().
-   *
-   * @see drupal_valid_test_ua()
+   * checked by \Drupal\Core\Test\UserAgent::validate().
    */
   protected function prepareRequest() {
     $session = $this->getSession();
-    $session->setCookie('SIMPLETEST_USER_AGENT', drupal_generate_test_ua($this->databasePrefix));
+    $session->setCookie('SIMPLETEST_USER_AGENT', UserAgent::generate(\Drupal::request(), $this->databasePrefix));
   }
 
   /**
diff --git a/update.php b/update.php
index 59e808ed24..63041f190a 100644
--- a/update.php
+++ b/update.php
@@ -8,6 +8,7 @@
  * See COPYRIGHT.txt and LICENSE.txt files in the "core" directory.
  */
 
+use Drupal\Core\Test\UserAgent;
 use Drupal\Core\Update\UpdateKernel;
 use Symfony\Component\HttpFoundation\Request;
 
@@ -17,13 +18,13 @@
 // update path will create so many objects that garbage collection causes
 // segmentation faults.
 require_once 'core/includes/bootstrap.inc';
-if (drupal_valid_test_ua()) {
+$request = Request::createFromGlobals();
+if (UserAgent::validate($request)) {
   gc_collect_cycles();
   gc_disable();
 }
 
 $kernel = new UpdateKernel('prod', $autoloader, FALSE);
-$request = Request::createFromGlobals();
 
 $response = $kernel->handle($request);
 $response->send();
