diff --git a/core/core.services.yml b/core/core.services.yml
index d21e0074da86938979b7d5b49819ae06672fd12c..3ce58886ddf3f855e870bb6c498a14bec01ad851 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -1879,3 +1879,4 @@ services:
       - '@logger.channel.default'
     tags:
       - { name: twig.loader, priority: 5 }
+  Drupal\Core\EventSubscriber\CsrfExceptionSubscriber: ~
diff --git a/core/lib/Drupal/Core/EventSubscriber/CsrfExceptionSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/CsrfExceptionSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..137bc189ace93de460bd38be12a31ac5a332ac1a
--- /dev/null
+++ b/core/lib/Drupal/Core/EventSubscriber/CsrfExceptionSubscriber.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\EventSubscriber;
+
+use Drupal\Core\Routing\RouteMatch;
+use Drupal\Core\Url;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpKernel\Event\ExceptionEvent;
+
+/**
+ * Handles exceptions related to CSRF access.
+ *
+ * Redirects CSRF 403 exceptions to a _csrf_confirm_form_route.
+ */
+class CsrfExceptionSubscriber extends HttpExceptionSubscriberBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getHandledFormats(): array {
+    return ['html'];
+  }
+
+  /**
+   * Handles a 403 error for HTML.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\ExceptionEvent $event
+   *   The event to process.
+   */
+  public function on403(ExceptionEvent $event): void {
+    $request = $event->getRequest();
+    $routeMatch = RouteMatch::createFromRequest($request);
+    $route = $routeMatch->getRouteObject();
+    if (!$route->hasRequirement('_csrf_token') || empty($route->getOption('_csrf_confirm_form_route'))) {
+      return;
+    }
+    $event->setResponse(new RedirectResponse(Url::fromRoute($route->getOption('_csrf_confirm_form_route'))->toString()));
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php
index f724b96eb5892712b3b13e9a468c1b94f1c8a210..71daad24ffca45ef0d8275bba91fe27cf5512962 100644
--- a/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php
@@ -538,6 +538,7 @@ public function testRead() {
     $this->drupalGet('/some/normal/route');
     $this->assertFalse($this->drupalUserIsLoggedIn($this->userCanViewProfiles));
     $this->container->get('state')->set('system.maintenance_mode', FALSE);
+    $this->drupalResetSession();
 
     // Test that admin user can bypass maintenance mode.
     $admin_user = $this->drupalCreateUser([], NULL, TRUE);
diff --git a/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php b/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php
index 6cad565e8fcc99801d287f47ca8b2123cc4c3298..9e28e03879d4390bfb797685b8876cee6739ea7a 100644
--- a/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php
+++ b/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php
@@ -183,13 +183,13 @@ public function testToolbar() {
     $this->drupalLogin($site_configuration_user1);
     $this->verifyDynamicPageCache($test_page_url, 'MISS');
     $this->verifyDynamicPageCache($test_page_url, 'HIT');
-    $this->assertCacheContexts(['user', 'url.query_args:_wrapper_format']);
+    $this->assertCacheContexts(['session', 'user', 'url.query_args:_wrapper_format']);
     $this->assertSession()->linkExists('Shortcuts');
     $this->assertSession()->linkExists('Cron');
 
     $this->drupalLogin($site_configuration_user2);
     $this->verifyDynamicPageCache($test_page_url, 'HIT');
-    $this->assertCacheContexts(['user', 'url.query_args:_wrapper_format']);
+    $this->assertCacheContexts(['session', 'user', 'url.query_args:_wrapper_format']);
     $this->assertSession()->linkExists('Shortcuts');
     $this->assertSession()->linkExists('Cron');
 
diff --git a/core/modules/toolbar/tests/src/Functional/ToolbarCacheContextsTest.php b/core/modules/toolbar/tests/src/Functional/ToolbarCacheContextsTest.php
index 656fa45077fd10019abfb873052532d2dd2d3f84..23b2140fd7213dc498f55cd9e85c5a9f34d63048 100644
--- a/core/modules/toolbar/tests/src/Functional/ToolbarCacheContextsTest.php
+++ b/core/modules/toolbar/tests/src/Functional/ToolbarCacheContextsTest.php
@@ -84,7 +84,7 @@ public function testCacheIntegration() {
    */
   public function testToolbarCacheContextsCaller() {
     // Test with default combination and permission to see toolbar.
-    $this->assertToolbarCacheContexts(['user'], 'Expected cache contexts found for default combination and permission to see toolbar.');
+    $this->assertToolbarCacheContexts(['user', 'session'], 'Expected cache contexts found for default combination and permission to see toolbar.');
 
     // Test without user toolbar tab. User module is a required module so we have to
     // manually remove the user toolbar tab.
diff --git a/core/modules/user/src/EventSubscriber/AccessDeniedSubscriber.php b/core/modules/user/src/EventSubscriber/AccessDeniedSubscriber.php
index 54d5275997eea6bd5beca9bcaa6c8252805ce6fa..f33b0a303dd877cb51c93e4c859219dae9e80002 100644
--- a/core/modules/user/src/EventSubscriber/AccessDeniedSubscriber.php
+++ b/core/modules/user/src/EventSubscriber/AccessDeniedSubscriber.php
@@ -65,7 +65,7 @@ public function onException(ExceptionEvent $event) {
       elseif ($route_name === 'user.page') {
         $redirect_url = Url::fromRoute('user.login', [], ['absolute' => TRUE]);
       }
-      elseif ($route_name === 'user.logout') {
+      elseif (in_array($route_name, ['user.logout', 'user.logout.confirm'], TRUE)) {
         $redirect_url = Url::fromRoute('<front>', [], ['absolute' => TRUE]);
       }
 
diff --git a/core/modules/user/src/Form/UserLogoutConfirm.php b/core/modules/user/src/Form/UserLogoutConfirm.php
new file mode 100644
index 0000000000000000000000000000000000000000..df2b1e27eb10212ae9b910aaf2ce1a89ad038d76
--- /dev/null
+++ b/core/modules/user/src/Form/UserLogoutConfirm.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\user\Form;
+
+use Drupal\Core\Form\ConfirmFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Form\WorkspaceSafeFormInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\Url;
+
+/**
+ * Provides a confirmation form for user logout.
+ */
+class UserLogoutConfirm extends ConfirmFormBase implements WorkspaceSafeFormInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfirmText(): TranslatableMarkup {
+    return $this->t('Log out');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getQuestion(): TranslatableMarkup {
+    return $this->t('Are you sure you want to log out?');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCancelUrl(): Url {
+    return new Url('<front>');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId(): string {
+    return 'user_logout_confirm';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state): void {
+    user_logout();
+    $form_state->setRedirect('<front>');
+  }
+
+}
diff --git a/core/modules/user/tests/src/Functional/UserLogoutTest.php b/core/modules/user/tests/src/Functional/UserLogoutTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..952b15b0711f137fa8a271a90fc8127e99690683
--- /dev/null
+++ b/core/modules/user/tests/src/Functional/UserLogoutTest.php
@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\user\Functional;
+
+use Drupal\Core\Url;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests user logout.
+ *
+ * @group user
+ */
+class UserLogoutTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['user', 'block'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() : void {
+    parent::setUp();
+    $this->placeBlock('system_menu_block:account');
+  }
+
+  /**
+   * Tests user logout functionality.
+   */
+  public function testLogout(): void {
+    $account = $this->createUser();
+    $this->drupalLogin($account);
+
+    // Test missing csrf token does not log the user out.
+    $logoutUrl = Url::fromRoute('user.logout');
+    $confirmUrl = Url::fromRoute('user.logout.confirm');
+    $this->drupalGet($logoutUrl);
+    $this->assertTrue($this->drupalUserIsLoggedIn($account));
+    $this->assertSession()->addressEquals($confirmUrl);
+
+    // Test invalid csrf token does not log the user out.
+    $this->drupalGet($logoutUrl, ['query' => ['token' => '123']]);
+    $this->assertTrue($this->drupalUserIsLoggedIn($account));
+    $this->assertSession()->addressEquals($confirmUrl);
+    // Submitting the confirmation form correctly logs the user out.
+    $this->submitForm([], 'Log out');
+    $this->assertFalse($this->drupalUserIsLoggedIn($account));
+
+    $this->drupalResetSession();
+    $this->drupalLogin($account);
+
+    // Test with valid logout link.
+    $this->drupalGet('user');
+    $this->getSession()->getPage()->clickLink('Log out');
+    $this->assertFalse($this->drupalUserIsLoggedIn($account));
+
+    // Test hitting the confirm form while logged out redirects to the
+    // frontpage.
+    $this->drupalGet($confirmUrl);
+    $this->assertSession()->addressEquals(Url::fromRoute('<front>'));
+  }
+
+}
diff --git a/core/modules/user/user.routing.yml b/core/modules/user/user.routing.yml
index d4799178457168db49ecd638cfce70fd69fb0688..206d8c01a13e911cf41f8ffa175c326b7640ce3b 100644
--- a/core/modules/user/user.routing.yml
+++ b/core/modules/user/user.routing.yml
@@ -12,6 +12,16 @@ user.logout:
     _controller: '\Drupal\user\Controller\UserController::logout'
   requirements:
     _user_is_logged_in: 'TRUE'
+    _csrf_token: 'TRUE'
+  options:
+    _csrf_confirm_form_route: 'user.logout.confirm'
+
+user.logout.confirm:
+  path: '/user/logout/confirm'
+  defaults:
+    _form: '\Drupal\user\Form\UserLogoutConfirm'
+  requirements:
+    _user_is_logged_in: 'TRUE'
 
 user.admin_index:
   path: '/admin/config/people'
diff --git a/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryAuthenticatedPerformanceTest.php b/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryAuthenticatedPerformanceTest.php
index 21afa6750af2c382771194a8aa6c046125e9657f..a9b7f6904697a0af062374ae5aff24effabc96e7 100644
--- a/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryAuthenticatedPerformanceTest.php
+++ b/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryAuthenticatedPerformanceTest.php
@@ -50,11 +50,11 @@ public function testFrontPageAuthenticatedWarmCache(): void {
     $recorded_queries = $performance_data->getQueries();
     $this->assertSame($expected_queries, $recorded_queries);
     $this->assertSame(4, $performance_data->getQueryCount());
-    $this->assertSame(45, $performance_data->getCacheGetCount());
+    $this->assertSame(48, $performance_data->getCacheGetCount());
     $this->assertSame(0, $performance_data->getCacheSetCount());
     $this->assertSame(0, $performance_data->getCacheDeleteCount());
     $this->assertSame(0, $performance_data->getCacheTagChecksumCount());
-    $this->assertSame(13, $performance_data->getCacheTagIsValidCount());
+    $this->assertSame(16, $performance_data->getCacheTagIsValidCount());
     $this->assertSame(0, $performance_data->getCacheTagInvalidationCount());
   }
 
diff --git a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php
index e6894ecca164d61d5553e969b50f10b5f7406956..2badae2a5af269c589751b496996830f91817416 100644
--- a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php
+++ b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php
@@ -221,11 +221,11 @@ public function testLogin(): void {
     $recorded_queries = $performance_data->getQueries();
     $this->assertSame($expected_queries, $recorded_queries);
     $this->assertSame(15, $performance_data->getQueryCount());
-    $this->assertSame(63, $performance_data->getCacheGetCount());
+    $this->assertSame(67, $performance_data->getCacheGetCount());
     $this->assertSame(1, $performance_data->getCacheSetCount());
     $this->assertSame(1, $performance_data->getCacheDeleteCount());
     $this->assertSame(1, $performance_data->getCacheTagChecksumCount());
-    $this->assertSame(28, $performance_data->getCacheTagIsValidCount());
+    $this->assertSame(30, $performance_data->getCacheTagIsValidCount());
     $this->assertSame(0, $performance_data->getCacheTagInvalidationCount());
   }
 
@@ -239,7 +239,6 @@ public function testLoginBlock(): void {
     // this twice so that any caches which take two requests to warm are also
     // covered.
     $account = $this->drupalCreateUser();
-    $this->drupalLogout();
 
     foreach (range(0, 1) as $index) {
       $this->drupalGet('node');
@@ -276,11 +275,11 @@ public function testLoginBlock(): void {
     $recorded_queries = $performance_data->getQueries();
     $this->assertSame($expected_queries, $recorded_queries);
     $this->assertSame(17, $performance_data->getQueryCount());
-    $this->assertSame(107, $performance_data->getCacheGetCount());
+    $this->assertSame(110, $performance_data->getCacheGetCount());
     $this->assertSame(1, $performance_data->getCacheSetCount());
     $this->assertSame(1, $performance_data->getCacheDeleteCount());
     $this->assertSame(1, $performance_data->getCacheTagChecksumCount());
-    $this->assertSame(43, $performance_data->getCacheTagIsValidCount());
+    $this->assertSame(46, $performance_data->getCacheTagIsValidCount());
     $this->assertSame(0, $performance_data->getCacheTagInvalidationCount());
   }
 
diff --git a/core/tests/Drupal/Nightwatch/Commands/drupalLogout.js b/core/tests/Drupal/Nightwatch/Commands/drupalLogout.js
index c5f4b47e5edf8e4989eda61a98bb3a2b62accf52..a58d68efc9aad8d132b65a46bf3c86a23eaeed4e 100644
--- a/core/tests/Drupal/Nightwatch/Commands/drupalLogout.js
+++ b/core/tests/Drupal/Nightwatch/Commands/drupalLogout.js
@@ -13,7 +13,9 @@
 exports.command = function drupalLogout({ silent = false } = {}, callback) {
   const self = this;
 
-  this.drupalRelativeURL('/user/logout');
+  this.drupalRelativeURL('/user/logout/confirm').submitForm(
+    '#user-logout-confirm',
+  );
 
   this.drupalUserIsLoggedIn((sessionExists) => {
     if (silent) {
diff --git a/core/tests/Drupal/Tests/UiHelperTrait.php b/core/tests/Drupal/Tests/UiHelperTrait.php
index 71d6bc97b8fac4081a61c3b04029a55256d0993f..c5ed82ba5dc241f75cbcba340c10d5822798a4d8 100644
--- a/core/tests/Drupal/Tests/UiHelperTrait.php
+++ b/core/tests/Drupal/Tests/UiHelperTrait.php
@@ -181,10 +181,18 @@ protected function drupalLogout() {
     // screen.
     $assert_session = $this->assertSession();
     $destination = Url::fromRoute('user.page')->toString();
-    $this->drupalGet(Url::fromRoute('user.logout', [], ['query' => ['destination' => $destination]]));
+    $this->drupalGet(Url::fromRoute('user.logout.confirm', options: ['query' => ['destination' => $destination]]));
+    $this->submitForm([], 'Log out');
     $assert_session->fieldExists('name');
     $assert_session->fieldExists('pass');
 
+    $this->drupalResetSession();
+  }
+
+  /**
+   * Resets the current active session back to Anonymous session.
+   */
+  protected function drupalResetSession(): void {
     // @see BrowserTestBase::drupalUserIsLoggedIn()
     unset($this->loggedInUser->sessionId);
     $this->loggedInUser = FALSE;