diff --git a/core/modules/system/src/Controller/TimezoneController.php b/core/modules/system/src/Controller/TimezoneController.php
index 06911324733a1180999b44d797a866162a8c6234..98db992468654785fb7f6be94315dfa28f293905 100644
--- a/core/modules/system/src/Controller/TimezoneController.php
+++ b/core/modules/system/src/Controller/TimezoneController.php
@@ -5,33 +5,48 @@
 use Symfony\Component\HttpFoundation\JsonResponse;
 
 /**
- * Provides a callback for finding out a timezone name.
+ * Provides a callback for finding a time zone identifier.
  */
 class TimezoneController {
 
   /**
-   * Retrieve a JSON object containing a time zone name given a timezone
-   * abbreviation.
+   * Returns a time zone identifier given a time zone abbreviation.
    *
    * @param string $abbreviation
    *   Time zone abbreviation.
    * @param int $offset
    *   Offset from GMT in seconds. Defaults to -1 which means that first found
-   *   time zone corresponding to abbr is returned. Otherwise exact offset is
-   *   searched and only if not found then the first time zone with any offset
-   *   is returned.
-   * @param null|bool $is_daylight_saving_time
-   *   Daylight saving time indicator. If abbr does not exist then the time
-   *   zone is searched solely by offset and isdst.
+   *   time zone corresponding to abbreviation is returned. Otherwise exact
+   *   offset is searched and only if not found then the first time zone with
+   *   any offset is returned.
+   * @param null|int $is_daylight_saving_time
+   *   Daylight saving time indicator. If abbreviation does not exist then the
+   *   time zone is searched solely by offset and is DST.
    *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
-   *   The timezone name in JsonResponse object.
+   *   The time zone identifier or 'false' in JsonResponse object.
    */
   public function getTimezone($abbreviation = '', $offset = -1, $is_daylight_saving_time = NULL) {
+    $offset = intval($offset);
+    // Out of bounds check for offset. Offset +/- UTC is typically no
+    // smaller/larger than -12/+14.
+    if ($offset < -60000 || $offset > 60000) {
+      return new JsonResponse(FALSE);
+    }
+
+    if (isset($is_daylight_saving_time)) {
+      $original = intval($is_daylight_saving_time);
+      $is_daylight_saving_time = min(1, max(-1, intval($is_daylight_saving_time)));
+      // Catch if out of boundary.
+      if ($original !== $is_daylight_saving_time) {
+        return new JsonResponse(FALSE);
+      }
+    }
+
     // An abbreviation of "0" passed in the callback arguments should be
     // interpreted as the empty string.
     $abbreviation = $abbreviation ? $abbreviation : '';
-    $timezone = timezone_name_from_abbr($abbreviation, intval($offset), $is_daylight_saving_time);
+    $timezone = timezone_name_from_abbr($abbreviation, $offset, $is_daylight_saving_time);
     return new JsonResponse($timezone);
   }
 
diff --git a/core/modules/system/system.routing.yml b/core/modules/system/system.routing.yml
index 00eddce6b5502a5e06dc5754bea2ffa47cb0d80e..f5335c48c179634ca9aa3d095ae0eae7f5093795 100644
--- a/core/modules/system/system.routing.yml
+++ b/core/modules/system/system.routing.yml
@@ -455,6 +455,9 @@ system.timezone:
     is_daylight_saving_time: NULL
   requirements:
     _access: 'TRUE'
+    abbreviation: '0|([A-Z]{3,5})'
+    offset: '\-?\d+'
+    is_daylight_saving_time: '\-1|0|1'
 
 system.admin_config:
   path: '/admin/config'
diff --git a/core/modules/system/tests/src/Functional/Datetime/DrupalDateTimeTest.php b/core/modules/system/tests/src/Functional/Datetime/DrupalDateTimeTest.php
index d2120571a23903bd942fab3ee04c82a7379d95ca..a319b2131f399eab1c9858e23e0da7e183c05c64 100644
--- a/core/modules/system/tests/src/Functional/Datetime/DrupalDateTimeTest.php
+++ b/core/modules/system/tests/src/Functional/Datetime/DrupalDateTimeTest.php
@@ -31,20 +31,6 @@ protected function setUp(): void {
 
   }
 
-  /**
-   * Tests that the AJAX Timezone Callback can deal with various formats.
-   */
-  public function testSystemTimezone() {
-    $options = [
-      'query' => [
-        'date' => 'Tue+Sep+17+2013+21%3A35%3A31+GMT%2B0100+(BST)#',
-      ],
-    ];
-    // Query the AJAX Timezone Callback with a long-format date.
-    $response = $this->drupalGet('system/timezone/BST/3600/1', $options);
-    $this->assertEquals('"Europe\\/London"', $response, 'Timezone AJAX callback successfully identifies and responds to a long-format date.');
-  }
-
   /**
    * Tests that DrupalDateTime can detect the right timezone to use.
    *
diff --git a/core/modules/system/tests/src/Functional/Datetime/TimeZoneAbbreviationRouteTest.php b/core/modules/system/tests/src/Functional/Datetime/TimeZoneAbbreviationRouteTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1cee7b46bbd19898f42438c6f0f9271259729af3
--- /dev/null
+++ b/core/modules/system/tests/src/Functional/Datetime/TimeZoneAbbreviationRouteTest.php
@@ -0,0 +1,137 @@
+<?php
+
+namespace Drupal\Tests\system\Functional\Datetime;
+
+use Drupal\Tests\BrowserTestBase;
+
+// cspell:ignore ABCDEFGHIJK
+
+/**
+ * Tests converting JavaScript time zone abbreviations to time zone identifiers.
+ *
+ * @group Datetime
+ */
+class TimeZoneAbbreviationRouteTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['system'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * Test that the AJAX Timezone Callback can deal with various formats.
+   */
+  public function testSystemTimezone() {
+    $options = [
+      'query' => [
+        'date' => 'Tue+Sep+17+2013+21%3A35%3A31+GMT%2B0100+(BST)#',
+      ],
+    ];
+    // Query the AJAX Timezone Callback with a long-format date.
+    $response = $this->drupalGet('system/timezone/BST/3600/1', $options);
+    $this->assertEquals($response, '"Europe\/London"');
+  }
+
+  /**
+   * Test the AJAX Timezone Callback with invalid inputs.
+   *
+   * @param string $path
+   *   Path to call.
+   * @param string|null $expectedResponse
+   *   Expected response, or NULL if expecting error.
+   * @param bool $expectInvalidRequest
+   *   Whether to expect the request is invalid.
+   *
+   * @dataProvider providerAbbreviationConversion
+   */
+  public function testAbbreviationConversion($path, $expectedResponse = NULL, $expectInvalidRequest = FALSE) {
+    $response = $this->drupalGet('system/timezone/' . $path);
+    if (isset($expectedResponse)) {
+      $this->assertEquals($response, $expectedResponse);
+    }
+    $this->assertSession()->statusCodeEquals($expectInvalidRequest ? 404 : 200);
+  }
+
+  /**
+   * Provides test data for testGet().
+   *
+   * @return array
+   *   Test scenarios.
+   */
+  public function providerAbbreviationConversion() {
+    return [
+      'valid, default offset' => [
+        'CST/0/0',
+        '"America\/Chicago"',
+      ],
+      // This should be the same TZID as default value.
+      'valid, default, explicit' => [
+        'CST/-1/0',
+        '"America\/Chicago"',
+      ],
+      // Same abbreviation but different offset.
+      'valid, default, alternative offset' => [
+        'CST/28800/0',
+        '"Asia\/Chongqing"',
+      ],
+      // Using '0' as offset will get the best matching time zone for an offset.
+      'valid, no abbreviation, offset, no DST' => [
+        '0/3600/0',
+        '"Europe\/Paris"',
+      ],
+      'valid, no abbreviation, offset, with DST' => [
+        '0/3600/1',
+        '"Europe\/London"',
+      ],
+      'invalid, unknown abbreviation' => [
+        'foo/0/0',
+        NULL,
+        FALSE,
+      ],
+      'invalid abbreviation, out of range (short)' => [
+        'A',
+        NULL,
+        TRUE,
+      ],
+      'invalid abbreviation, out of range (long)' => [
+        'ABCDEFGHIJK',
+        NULL,
+        TRUE,
+      ],
+      'invalid offset, non integer' => [
+        'CST/foo',
+        NULL,
+        TRUE,
+      ],
+      'invalid offset, out of range (lower)' => [
+        'CST/-100000',
+        'false',
+      ],
+      'invalid offset, out of range (higher)' => [
+        'CST/100000',
+        'false',
+      ],
+      'invalid DST value' => [
+        'CST/3600/blah',
+        NULL,
+        TRUE,
+      ],
+      'invalid DST value, out of range (lower)' => [
+        'CST/3600/-2',
+        NULL,
+        TRUE,
+      ],
+      'invalid DST value, out of range (higher)' => [
+        'CST/3600/2',
+        NULL,
+        TRUE,
+      ],
+    ];
+  }
+
+}