diff --git a/core/lib/Drupal/Core/DrupalKernel.php b/core/lib/Drupal/Core/DrupalKernel.php
index 78b728ebb83f0d824a131b54bb23c9693a4fd142..11da24a2bc4f52da7006833bff3c6d9f2c436528 100644
--- a/core/lib/Drupal/Core/DrupalKernel.php
+++ b/core/lib/Drupal/Core/DrupalKernel.php
@@ -1009,6 +1009,7 @@ public static function bootEnvironment($app_root = NULL) {
       // Use session cookies, not transparent sessions that puts the session id
       // in the query string.
       ini_set('session.use_cookies', '1');
+      ini_set('session.use_strict_mode', '1');
       if (\PHP_VERSION_ID < 80400) {
         ini_set('session.use_only_cookies', '1');
         ini_set('session.use_trans_sid', '0');
diff --git a/core/lib/Drupal/Core/Session/SessionHandler.php b/core/lib/Drupal/Core/Session/SessionHandler.php
index fe1247158cd143850eca8c024eafa503907fd8dd..2a17f8f9a51b77e45a900475e4ceb156ce69c8bd 100644
--- a/core/lib/Drupal/Core/Session/SessionHandler.php
+++ b/core/lib/Drupal/Core/Session/SessionHandler.php
@@ -8,12 +8,12 @@
 use Drupal\Core\Database\DatabaseException;
 use Drupal\Core\DependencyInjection\DependencySerializationTrait;
 use Symfony\Component\HttpFoundation\RequestStack;
-use Symfony\Component\HttpFoundation\Session\Storage\Proxy\AbstractProxy;
+use Symfony\Component\HttpFoundation\Session\Storage\Handler\AbstractSessionHandler;
 
 /**
  * Default session handler.
  */
-class SessionHandler extends AbstractProxy implements \SessionHandlerInterface {
+class SessionHandler extends AbstractSessionHandler implements \SessionHandlerInterface, \SessionUpdateTimestampHandlerInterface {
 
   use DependencySerializationTrait;
 
@@ -44,13 +44,13 @@ public function open(string $save_path, string $name): bool {
   /**
    * {@inheritdoc}
    */
-  public function read(#[\SensitiveParameter] string $sid): string|false {
+  public function doRead(#[\SensitiveParameter] string $sessionId): string {
     $data = '';
-    if (!empty($sid)) {
+    if (!empty($sessionId)) {
       try {
         // Read the session data from the database.
         $query = $this->connection
-          ->queryRange('SELECT [session] FROM {sessions} WHERE [sid] = :sid', 0, 1, [':sid' => Crypt::hashBase64($sid)]);
+          ->queryRange('SELECT [session] FROM {sessions} WHERE [sid] = :sid', 0, 1, [':sid' => Crypt::hashBase64($sessionId)]);
         $data = (string) $query->fetchField();
       }
       // Swallow the error if the table hasn't been created yet.
@@ -63,18 +63,18 @@ public function read(#[\SensitiveParameter] string $sid): string|false {
   /**
    * {@inheritdoc}
    */
-  public function write(#[\SensitiveParameter] string $sid, string $value): bool {
+  public function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool {
     $try_again = FALSE;
     $request = $this->requestStack->getCurrentRequest();
     $fields = [
       'uid' => $request->getSession()->get('uid', 0),
       'hostname' => $request->getClientIP(),
-      'session' => $value,
+      'session' => $data,
       'timestamp' => $this->time->getRequestTime(),
     ];
     $doWrite = fn() =>
       $this->connection->merge('sessions')
-        ->keys(['sid' => Crypt::hashBase64($sid)])
+        ->keys(['sid' => Crypt::hashBase64($sessionId)])
         ->fields($fields)
         ->execute();
     try {
@@ -106,11 +106,18 @@ public function close(): bool {
   /**
    * {@inheritdoc}
    */
-  public function destroy(#[\SensitiveParameter] string $sid): bool {
+  public function destroy(#[\SensitiveParameter] string $sessionId): bool {
+    return $this->doDestroy($sessionId);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool {
     try {
       // Delete session data.
       $this->connection->delete('sessions')
-        ->condition('sid', Crypt::hashBase64($sid))
+        ->condition('sid', Crypt::hashBase64($sessionId))
         ->execute();
     }
     // Swallow the error if the table hasn't been created yet.
@@ -140,6 +147,16 @@ public function gc(int $lifetime): int|false {
     return FALSE;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool {
+    // This function is intentionally a no-op. Drupal manages session expiry in
+    // the MetadataBag, and the timestamp should not be updated here.
+    // @see \Drupal\Core\Session\MetadataBag::__construct()
+    return TRUE;
+  }
+
   /**
    * Defines the schema for the session table.
    *
diff --git a/core/lib/Drupal/Core/Session/WriteSafeSessionHandler.php b/core/lib/Drupal/Core/Session/WriteSafeSessionHandler.php
index 3c1f61ece10a9814b654c6df985019c326b3548c..b4e39ebd626bd9d68ad0fc026aa1bcabe73c8369 100644
--- a/core/lib/Drupal/Core/Session/WriteSafeSessionHandler.php
+++ b/core/lib/Drupal/Core/Session/WriteSafeSessionHandler.php
@@ -2,15 +2,12 @@
 
 namespace Drupal\Core\Session;
 
+use Symfony\Component\HttpFoundation\Session\Storage\Proxy\SessionHandlerProxy;
+
 /**
  * Wraps the session handler to prevent writes when not necessary or allowed.
  */
-class WriteSafeSessionHandler implements \SessionHandlerInterface, WriteSafeSessionHandlerInterface {
-
-  /**
-   * @var \SessionHandlerInterface
-   */
-  protected $wrappedSessionHandler;
+class WriteSafeSessionHandler extends SessionHandlerProxy implements \SessionHandlerInterface, WriteSafeSessionHandlerInterface, \SessionUpdateTimestampHandlerInterface {
 
   /**
    * Whether or not the session is enabled for writing.
@@ -30,49 +27,21 @@ class WriteSafeSessionHandler implements \SessionHandlerInterface, WriteSafeSess
   /**
    * Constructs a new write safe session handler.
    *
-   * @param \SessionHandlerInterface $wrapped_session_handler
+   * @param \SessionHandlerInterface $handler
    *   The underlying session handler.
    * @param bool $session_writable
    *   Whether or not the session should be initially writable.
    */
-  public function __construct(\SessionHandlerInterface $wrapped_session_handler, $session_writable = TRUE) {
-    $this->wrappedSessionHandler = $wrapped_session_handler;
+  public function __construct(\SessionHandlerInterface $handler, $session_writable = TRUE) {
+    parent::__construct($handler);
     $this->sessionWritable = $session_writable;
   }
 
   /**
    * {@inheritdoc}
    */
-  public function close(): bool {
-    return $this->wrappedSessionHandler->close();
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function destroy($session_id): bool {
-    return $this->wrappedSessionHandler->destroy($session_id);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function gc($max_lifetime): int|FALSE {
-    return $this->wrappedSessionHandler->gc($max_lifetime);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function open($save_path, $session_id): bool {
-    return $this->wrappedSessionHandler->open($save_path, $session_id);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function read($session_id): string|FALSE {
-    $value = $this->wrappedSessionHandler->read($session_id);
+  public function read(#[\SensitiveParameter] string $session_id): string {
+    $value = $this->handler->read($session_id);
     $this->readSessions[$session_id] = $value;
     return $value;
   }
@@ -80,13 +49,13 @@ public function read($session_id): string|FALSE {
   /**
    * {@inheritdoc}
    */
-  public function write($session_id, $session_data): bool {
+  public function write(#[\SensitiveParameter] string $session_id, string $session_data): bool {
     // Only write the session when it has been modified.
     if (isset($this->readSessions[$session_id]) && $this->readSessions[$session_id] === $session_data) {
       return TRUE;
     }
     if ($this->isSessionWritable()) {
-      return $this->wrappedSessionHandler->write($session_id, $session_data);
+      return $this->handler->write($session_id, $session_data);
     }
     return TRUE;
   }
diff --git a/core/modules/system/tests/modules/session_test/session_test.routing.yml b/core/modules/system/tests/modules/session_test/session_test.routing.yml
index fa4b34b0498207363329b0fc34b86d2eee5baf1d..fe85de11032d5da6b7e2a5df2459e12688799d88 100644
--- a/core/modules/system/tests/modules/session_test/session_test.routing.yml
+++ b/core/modules/system/tests/modules/session_test/session_test.routing.yml
@@ -98,6 +98,14 @@ session_test.trace_handler:
   requirements:
     _access: 'TRUE'
 
+session_test.trace_handler_rewrite_unmodified:
+  path: '/session-test/trace-handler-rewrite-unmodified'
+  defaults:
+    _title: 'Returns a trace of rewritten but unmodified session data recorded by test proxy session handlers as JSON'
+    _controller: '\Drupal\session_test\Controller\SessionTestController::traceHandlerRewriteUnmodified'
+  requirements:
+    _access: 'TRUE'
+
 session_test.get_session_basic_auth:
   path: '/session-test/get-session'
   defaults:
diff --git a/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php b/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php
index 9db12e830df371a60bbb2ca62c32f94ce5a9b24b..9c7bb97e24b8575db07af6d2b10027ff70fc8549 100644
--- a/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php
+++ b/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php
@@ -170,6 +170,43 @@ public function traceHandler(Request $request) {
     return new JsonResponse($trace);
   }
 
+  /**
+   * Returns an updated trace recorded by test proxy session handlers as JSON.
+   *
+   * The session data is rewritten without modification to invoke
+   * `\SessionUpdateTimestampHandlerInterface::updateTimestamp`.
+   *
+   * Expects that there is an existing stacked session handler trace as recorded
+   * by `traceHandler()`.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The incoming request.
+   *
+   * @return \Symfony\Component\HttpFoundation\JsonResponse
+   *   The response.
+   *
+   * @throws \AssertionError
+   */
+  public function traceHandlerRewriteUnmodified(Request $request) {
+    // Assert that there is an existing session with stacked handler trace data.
+    assert(
+      is_int($_SESSION['trace-handler']) && $_SESSION['trace-handler'] > 0,
+      'Existing stacked session handler trace not found'
+    );
+
+    // Save unmodified session data.
+    assert(
+      ini_get('session.lazy_write'),
+      'session.lazy_write must be enabled to invoke updateTimestamp()'
+    );
+    $request->getSession()->save();
+
+    // Collect traces and return them in JSON format.
+    $trace = \Drupal::service('session_test.session_handler_proxy_trace')->getArrayCopy();
+
+    return new JsonResponse($trace);
+  }
+
   /**
    * Returns the values stored in the active session and the user ID.
    *
diff --git a/core/modules/system/tests/modules/session_test/src/Session/TestSessionHandlerProxy.php b/core/modules/system/tests/modules/session_test/src/Session/TestSessionHandlerProxy.php
index 15d05f4dd0f8b088195fb7a57feee583e89006da..d92031af32fb7667f438dcea7a143b2515cbe995 100644
--- a/core/modules/system/tests/modules/session_test/src/Session/TestSessionHandlerProxy.php
+++ b/core/modules/system/tests/modules/session_test/src/Session/TestSessionHandlerProxy.php
@@ -7,12 +7,12 @@
 /**
  * Provides a test session handler proxy.
  */
-class TestSessionHandlerProxy implements \SessionHandlerInterface {
+class TestSessionHandlerProxy implements \SessionHandlerInterface, \SessionUpdateTimestampHandlerInterface {
 
   /**
    * The decorated session handler.
    *
-   * @var \SessionHandlerInterface
+   * @var \SessionHandlerInterface&\SessionUpdateTimestampHandlerInterface
    */
   protected $sessionHandler;
 
@@ -31,7 +31,7 @@ class TestSessionHandlerProxy implements \SessionHandlerInterface {
    * @param mixed $optional_argument
    *   (optional) An optional argument.
    */
-  public function __construct(\SessionHandlerInterface $session_handler, $optional_argument = NULL) {
+  public function __construct(\SessionHandlerInterface&\SessionUpdateTimestampHandlerInterface $session_handler, $optional_argument = NULL) {
     $this->sessionHandler = $session_handler;
     $this->optionalArgument = $optional_argument;
   }
@@ -94,4 +94,26 @@ public function gc($max_lifetime): int|FALSE {
     return $this->sessionHandler->gc($max_lifetime);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function validateId($session_id): bool {
+    $trace = \Drupal::service('session_test.session_handler_proxy_trace');
+    $trace[] = ['BEGIN', $this->optionalArgument, __FUNCTION__, $session_id];
+    $result = $this->sessionHandler->validateId($session_id);
+    $trace[] = ['END', $this->optionalArgument, __FUNCTION__, $session_id];
+    return $result;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function updateTimestamp($session_id, $session_data): bool {
+    $trace = \Drupal::service('session_test.session_handler_proxy_trace');
+    $trace[] = ['BEGIN', $this->optionalArgument, __FUNCTION__, $session_id];
+    $result = $this->sessionHandler->updateTimestamp($session_id, $session_data);
+    $trace[] = ['END', $this->optionalArgument, __FUNCTION__, $session_id];
+    return $result;
+  }
+
 }
diff --git a/core/modules/system/tests/src/Functional/Session/SessionTest.php b/core/modules/system/tests/src/Functional/Session/SessionTest.php
index f32462f63a8c11407f6fd1af93e472f9b72beb2b..bf5d3bd409fd1767055dd214f01af845dd0127f1 100644
--- a/core/modules/system/tests/src/Functional/Session/SessionTest.php
+++ b/core/modules/system/tests/src/Functional/Session/SessionTest.php
@@ -4,6 +4,7 @@
 
 namespace Drupal\Tests\system\Functional\Session;
 
+use Drupal\Component\Utility\Crypt;
 use Drupal\Core\Database\Database;
 use Drupal\Tests\BrowserTestBase;
 
@@ -178,6 +179,75 @@ public function testSessionPersistenceOnLogin(): void {
     $this->assertSession()->pageTextContains('foobar');
   }
 
+  /**
+   * Tests that an invalid session ID in the cookie is rejected.
+   *
+   * @covers \Drupal\Core\Session\SessionManager::start
+   */
+  public function testAnonymousSessionFixation(): void {
+
+    $mink = $this->getSession();
+    $connection = Database::getConnection();
+
+    // Initialize a session for anonymous user.
+    $this->drupalGet('session-test/set/foo');
+
+    // Switch browser cookie to arbitrary session_id.
+    $session_cookie_name = $this->getSessionName();
+    $initial_session_cookie_value = $mink->getCookie($session_cookie_name);
+
+    $mink->restart();
+    $this->initFrontPage();
+    // Session restart always resets all the cookies by design, so we
+    // set an arbitrary session_id in the cookie for the next request.
+    $invalid_session_cookie_value = bin2hex($this->randomMachineName(13));
+    $mink->setCookie($session_cookie_name, $invalid_session_cookie_value);
+
+    // Make another request.
+    sleep(1);
+    $this->drupalGet('session-test/set/bar');
+
+    // Check returned cookie value.
+    $returned_session_cookie_value = $mink->getCookie($session_cookie_name);
+
+    // The backend should reject $invalid_session_cookie_value and return a
+    // new session_id that's different from both the first and the invalid
+    // SIDs.
+    $this->assertNotEquals(
+      $initial_session_cookie_value,
+      $returned_session_cookie_value,
+      'Returned session ID is not equal to initial session ID'
+    );
+
+    $this->assertNotEquals(
+      $invalid_session_cookie_value,
+      $returned_session_cookie_value,
+      'Returned session ID is not equal to invalid session ID'
+    );
+
+    // Check that invalid SID does not exist in database.
+    $this->assertEmpty(
+      $connection
+        ->select('sessions', 's')
+        ->fields('s', ['timestamp'])
+        ->condition('sid', Crypt::hashBase64($invalid_session_cookie_value))
+        ->execute()
+        ->fetchField(),
+        'Invalid session ID is not in database'
+    );
+
+    // Check that returned SID does exist in database.
+    $this->assertNotEmpty(
+      $connection
+        ->select('sessions', 's')
+        ->fields('s', ['timestamp'])
+        ->condition('sid', Crypt::hashBase64($returned_session_cookie_value))
+        ->execute()
+        ->fetchField(),
+        'Returned session ID is in database'
+    );
+  }
+
   /**
    * Tests that empty anonymous sessions are destroyed.
    */
diff --git a/core/modules/system/tests/src/Functional/Session/StackSessionHandlerIntegrationTest.php b/core/modules/system/tests/src/Functional/Session/StackSessionHandlerIntegrationTest.php
index 0f3856140cf949479f949d8dc094d4dbdc62c3d0..4a464d54954809d0bcf4e934df5b721a83508091 100644
--- a/core/modules/system/tests/src/Functional/Session/StackSessionHandlerIntegrationTest.php
+++ b/core/modules/system/tests/src/Functional/Session/StackSessionHandlerIntegrationTest.php
@@ -53,4 +53,96 @@ public function testRequest(): void {
     $this->assertEquals($expect_trace, $actual_trace);
   }
 
+  /**
+   * Tests a session modify request with a valid session cookie.
+   *
+   * The trace should include `validateId` because a session cookie is included.
+   *
+   * The trace should include `write` but not include `updateTimestamp` because
+   * the session data is modified.
+   */
+  public function testRequestWriteInvokesValidateId(): void {
+    $options['query'][MainContentViewSubscriber::WRAPPER_FORMAT] = 'drupal_ajax';
+    $headers = ['X-Requested-With' => 'XMLHttpRequest'];
+
+    // Call the write trace handler to store the trace and retrieve a session
+    // cookie.
+    $this->drupalGet('session-test/trace-handler');
+
+    // Call the write trace handler again with the session cookie to modify
+    // the session data.
+    $actual_trace = json_decode($this->drupalGet('session-test/trace-handler', $options, $headers));
+    $sessionId = $this->getSessionCookies()->getCookieByName($this->getSessionName())->getValue();
+
+    $expect_trace = [
+      ["BEGIN", "test_argument", "open"],
+      ["BEGIN", NULL, "open"],
+      ["END", NULL, "open"],
+      ["END", "test_argument", "open"],
+      ["BEGIN", "test_argument", "validateId", $sessionId],
+      ["BEGIN", NULL, "validateId", $sessionId],
+      ["END", NULL, "validateId", $sessionId],
+      ["END", "test_argument", "validateId", $sessionId],
+      ["BEGIN", "test_argument", "read", $sessionId],
+      ["BEGIN", NULL, "read", $sessionId],
+      ["END", NULL, "read", $sessionId],
+      ["END", "test_argument", "read", $sessionId],
+      ["BEGIN", "test_argument", "write", $sessionId],
+      ["BEGIN", NULL, "write", $sessionId],
+      ["END", NULL, "write", $sessionId],
+      ["END", "test_argument", "write", $sessionId],
+      ["BEGIN", "test_argument", "close"],
+      ["BEGIN", NULL, "close"],
+      ["END", NULL, "close"],
+      ["END", "test_argument", "close"],
+    ];
+    $this->assertEquals($expect_trace, $actual_trace);
+  }
+
+  /**
+   * Tests a session rewrite-unmodified request with a valid session cookie.
+   *
+   * The trace should include `validateId` because a session cookie is included.
+   *
+   * The trace should include `updateTimestamp` but not include `write` because
+   * the session data is rewritten without modification and `session.lazy_write`
+   * is enabled.
+   */
+  public function testRequestWriteInvokesUpdateTimestamp(): void {
+    $options['query'][MainContentViewSubscriber::WRAPPER_FORMAT] = 'drupal_ajax';
+    $headers = ['X-Requested-With' => 'XMLHttpRequest'];
+
+    // Call the write trace handler to store the trace and retrieve a session
+    // cookie.
+    $this->drupalGet('session-test/trace-handler');
+
+    // Call the rewrite-unmodified trace handler with the session cookie.
+    $actual_trace = json_decode($this->drupalGet('session-test/trace-handler-rewrite-unmodified', $options, $headers));
+    $sessionId = $this->getSessionCookies()->getCookieByName($this->getSessionName())->getValue();
+
+    $expect_trace = [
+      ["BEGIN", "test_argument", "open"],
+      ["BEGIN", NULL, "open"],
+      ["END", NULL, "open"],
+      ["END", "test_argument", "open"],
+      ["BEGIN", "test_argument", "validateId", $sessionId],
+      ["BEGIN", NULL, "validateId", $sessionId],
+      ["END", NULL, "validateId", $sessionId],
+      ["END", "test_argument", "validateId", $sessionId],
+      ["BEGIN", "test_argument", "read", $sessionId],
+      ["BEGIN", NULL, "read", $sessionId],
+      ["END", NULL, "read", $sessionId],
+      ["END", "test_argument", "read", $sessionId],
+      ["BEGIN", "test_argument", "updateTimestamp", $sessionId],
+      ["BEGIN", NULL, "updateTimestamp", $sessionId],
+      ["END", NULL, "updateTimestamp", $sessionId],
+      ["END", "test_argument", "updateTimestamp", $sessionId],
+      ["BEGIN", "test_argument", "close"],
+      ["BEGIN", NULL, "close"],
+      ["END", NULL, "close"],
+      ["END", "test_argument", "close"],
+    ];
+    $this->assertEquals($expect_trace, $actual_trace);
+  }
+
 }