 core/lib/Drupal/Core/Access/RouteProcessorCsrf.php | 35 ++++++++++++++++++----
 core/lib/Drupal/Core/Render/BubbleableMetadata.php | 21 +++++++++++++
 2 files changed, 51 insertions(+), 5 deletions(-)

diff --git a/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php b/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php
index b5ebc5a..c830a8a 100644
--- a/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php
+++ b/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php
@@ -45,13 +45,38 @@ public function processOutbound($route_name, Route $route, array &$parameters, B
       }
       // Adding this to the parameters means it will get merged into the query
       // string when the route is compiled.
-      $parameters['token'] = $this->csrfToken->get($path);
-      if ($bubbleable_metadata) {
-        // Tokens are per user and per session, so not cacheable.
-        // @todo Improve in https://www.drupal.org/node/2351015.
-        $bubbleable_metadata->setCacheMaxAge(0);
+      if (!$bubbleable_metadata) {
+        $parameters['token'] = $this->csrfToken->get($path);
+      }
+      else {
+        // Generate a placeholder and a render array to replace it.
+        $placeholder = hash('sha1', $path);
+        $placeholder_render_array = [
+          '#lazy_builder' => ['route_processor_csrf:renderPlaceholderCsrfToken', [$path]],
+          // Tokens are per user and per session.
+          '#cache' => ['contexts' => ['user']],
+        ];
+
+        // Instead of setting an actual CSRF token as the query string, we set
+        // the placeholder, which will be replaced at the very last moment. This
+        // ensures links with CSRF tokens don't break cacheability.
+        $parameters['token'] = $placeholder;
+        $bubbleable_metadata->addAttachments(['placeholders' => [$placeholder => $placeholder_render_array]]);
       }
     }
   }
 
+  /**
+   * #lazy_builder callback; gets a CSRF token for the given path.
+   *
+   * @param string $path
+   *   The path to get a CSRF token for.
+   *
+   * @return array
+   *   A renderable array representing the CSRF token.
+   */
+  public function renderPlaceholderCsrfToken($path) {
+    return ['#markup' => $this->csrfToken->get($path)];
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Render/BubbleableMetadata.php b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
index dd53b73..81e7237 100644
--- a/core/lib/Drupal/Core/Render/BubbleableMetadata.php
+++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
@@ -74,6 +74,27 @@ public static function createFromRenderArray(array $build) {
   }
 
   /**
+   * Creates a bubbleable metadata object from a depended object.
+   *
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface|mixed $object
+   *   The object whose cacheability metadata to retrieve. If it implements
+   *   CacheableDependencyInterface, its cacheability metadata will be used,
+   *   otherwise, the passed in object must be assumed to be uncacheable, so
+   *   max-age 0 is set.
+   *
+   * @return static
+   */
+  public static function createFromObject($object) {
+    $meta = parent::createFromObject($object);
+
+    if ($object instanceof AttachmentsInterface) {
+      $meta->attachments = $object->getAttachments();
+    }
+
+    return $meta;
+  }
+
+  /**
    * Merges two attachments arrays (which live under the '#attached' key).
    *
    * The values under the 'drupalSettings' key are merged in a special way, to
