diff --git a/core/core.services.yml b/core/core.services.yml
index 53a2725..cc3fdcc 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -1057,7 +1057,6 @@ services:
     class: Drupal\Core\Access\RouteProcessorCsrf
     tags:
       - { name: route_processor_outbound }
-    arguments: ['@csrf_token']
   transliteration:
     class: Drupal\Core\Transliteration\PhpTransliteration
     arguments: [null, '@module_handler']
diff --git a/core/lib/Drupal/Core/Access/CsrfAccessCheck.php b/core/lib/Drupal/Core/Access/CsrfAccessCheck.php
index 61b882d..c1e96d4 100644
--- a/core/lib/Drupal/Core/Access/CsrfAccessCheck.php
+++ b/core/lib/Drupal/Core/Access/CsrfAccessCheck.php
@@ -22,6 +22,11 @@
 class CsrfAccessCheck implements RoutingAccessInterface {
 
   /**
+   * The route requirement key.
+   */
+  const ROUTE_REQUIREMENT_KEY = '_csrf_token';
+
+  /**
    * The CSRF token generator.
    *
    * @var \Drupal\Core\Access\CsrfTokenGenerator
@@ -52,14 +57,7 @@ public function __construct(CsrfTokenGenerator $csrf_token) {
    *   The access result.
    */
   public function access(Route $route, Request $request, RouteMatchInterface $route_match) {
-    $parameters = $route_match->getRawParameters();
-    $path = ltrim($route->getPath(), '/');
-    // Replace the path parameters with values from the parameters array.
-    foreach ($parameters as $param => $value) {
-      $path = str_replace("{{$param}}", $value, $path);
-    }
-
-    if ($this->csrfToken->validate($request->query->get('token'), $path)) {
+    if ($this->csrfToken->validate($request->query->get('token'), $route->getRequirement(static::ROUTE_REQUIREMENT_KEY))) {
       $result = AccessResult::allowed();
     }
     else {
diff --git a/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php b/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php
index 49873fa..5aa449e 100644
--- a/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php
+++ b/core/lib/Drupal/Core/Access/RouteProcessorCsrf.php
@@ -18,35 +18,16 @@
 class RouteProcessorCsrf implements OutboundRouteProcessorInterface {
 
   /**
-   * The CSRF token generator.
-   *
-   * @var \Drupal\Core\Access\CsrfTokenGenerator
+   * The route requirement key.
    */
-  protected $csrfToken;
-
-  /**
-   * Constructs a RouteProcessorCsrf object.
-   *
-   * @param \Drupal\Core\Access\CsrfTokenGenerator $csrf_token
-   *   The CSRF token generator.
-   */
-  function __construct(CsrfTokenGenerator $csrf_token) {
-    $this->csrfToken = $csrf_token;
-  }
+  const ROUTE_REQUIREMENT_KEY = '_csrf_token';
 
   /**
    * {@inheritdoc}
    */
   public function processOutbound($route_name, Route $route, array &$parameters) {
-    if ($route->hasRequirement('_csrf_token')) {
-      $path = ltrim($route->getPath(), '/');
-      // Replace the path parameters with values from the parameters array.
-      foreach ($parameters as $param => $value) {
-        $path = str_replace("{{$param}}", $value, $path);
-      }
-      // 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 ($route->hasRequirement(static::ROUTE_REQUIREMENT_KEY)) {
+      $parameters['token'] = 'csrf_token_placeholder:' . $route->getRequirement(static::ROUTE_REQUIREMENT_KEY);
     }
   }
 
diff --git a/core/lib/Drupal/Core/Cache/CacheableMetadata.php b/core/lib/Drupal/Core/Cache/CacheableMetadata.php
new file mode 100644
index 0000000..3278a1f
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/CacheableMetadata.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * @file
+ * Contains \Drupal\Core\CacheableMetadata
+ */
+
+namespace Drupal\Core\Cache;
+
+/**
+ * Defines an interface for objects which may be used by other cached objects.
+ *
+ * This extended interface defines setters so that the object may have cache
+ * metadata added by multiple actors.
+ *
+ * @ingroup cache
+ */
+class CacheableMetadata implements CacheableMetadataInterface {
+
+  /**
+   * Cache contexts.
+   *
+   * @var string[]
+   */
+  protected $contexts = [];
+
+  /**
+   * Cache tags.
+   *
+   * @var string[]
+   */
+  protected $tags = [];
+
+  /**
+   * Cache max-age.
+   *
+   * @var int
+   */
+  protected $maxAge = Cache::PERMANENT;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheTags() {
+    return $this->tags;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function addCacheTags(array $cache_tags) {
+    $this->tags = Cache::mergeTags($this->tags, $cache_tags);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setCacheTags(array $cache_tags) {
+    $this->tags = $cache_tags;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return $this->contexts;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function addCacheContexts(array $cache_contexts) {
+    $this->contexts = Cache::mergeContexts($this->contexts, $cache_contexts);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setCacheContexts(array $cache_contexts) {
+    $this->contexts = $cache_contexts;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheMaxAge() {
+    return $this->maxAge;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setCacheMaxAge($max_age) {
+    if (!is_int($max_age)) {
+      throw new \InvalidArgumentException('$max_age must be an integer');
+    }
+
+    $this->maxAge = $max_age;
+    return $this;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Cache/CacheableMetadataInterface.php b/core/lib/Drupal/Core/Cache/CacheableMetadataInterface.php
new file mode 100644
index 0000000..dff1b02
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/CacheableMetadataInterface.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * @file
+ * Contains \Drupal\Core\CacheableMetadataInterface
+ */
+
+namespace Drupal\Core\Cache;
+
+/**
+ * Defines an interface for objects which may be used by other cached objects.
+ *
+ * This extended interface defines setters so that the object may have cache
+ * metadata added by multiple actors.
+ *
+ * @ingroup cache
+ */
+interface CacheableMetadataInterface extends CacheableDependencyInterface {
+
+  /**
+   * Adds cache tags.
+   *
+   * @param string[] $cache_tags
+   *   The cache tags to be added.
+   *
+   * @return $this
+   */
+  public function addCacheTags(array $cache_tags);
+
+  /**
+   * Sets cache tags.
+   *
+   * @param string[] $cache_tags
+   *   The cache tags to be associated.
+   *
+   * @return $this
+   */
+  public function setCacheTags(array $cache_tags);
+
+  /**
+   * Adds cache contexts.
+   *
+   * @param string[] $cache_contexts
+   *   The cache contexts to be added.
+   *
+   * @return $this
+   */
+  public function addCacheContexts(array $cache_contexts);
+
+  /**
+   * Sets cache contexts.
+   *
+   * @param string[] $cache_contexts
+   *   The cache contexts to be associated.
+   *
+   * @return $this
+   */
+  public function setCacheContexts(array $cache_contexts);
+
+  /**
+   * Sets the maximum age (in seconds).
+   *
+   * Defaults to Cache::PERMANENT
+   *
+   * @param int $max_age
+   *   The max age to associate.
+   *
+   * @return $this
+   *
+   * @throws \InvalidArgumentException
+   */
+  public function setCacheMaxAge($max_age);
+
+}
diff --git a/core/lib/Drupal/Core/Render/BubbleableMetadata.php b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
index bf7812e..b51c055 100644
--- a/core/lib/Drupal/Core/Render/BubbleableMetadata.php
+++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
@@ -10,13 +10,14 @@
 use Drupal\Component\Utility\NestedArray;
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\CacheableMetadata;
 
 /**
  * Value object used for bubbleable rendering metadata.
  *
  * @see \Drupal\Core\Render\RendererInterface::render()
  */
-class BubbleableMetadata implements CacheableDependencyInterface {
+class BubbleableMetadata extends CacheableMetadata {
 
   /**
    * Cache contexts.
@@ -133,106 +134,6 @@ public static function createFromObject($object) {
   }
 
   /**
-   * Gets cache tags.
-   *
-   * @return string[]
-   */
-  public function getCacheTags() {
-    return $this->tags;
-  }
-
-  /**
-   * Adds cache tags.
-   *
-   * @param string[] $cache_tags
-   *   The cache tags to be added.
-   *
-   * @return $this
-   */
-  public function addCacheTags(array $cache_tags) {
-    $this->tags = Cache::mergeTags($this->tags, $cache_tags);
-    return $this;
-  }
-
-  /**
-   * Sets cache tags.
-   *
-   * @param string[] $cache_tags
-   *   The cache tags to be associated.
-   *
-   * @return $this
-   */
-  public function setCacheTags(array $cache_tags) {
-    $this->tags = $cache_tags;
-    return $this;
-  }
-
-  /**
-   * Gets cache contexts.
-   *
-   * @return string[]
-   */
-  public function getCacheContexts() {
-    return $this->contexts;
-  }
-
-  /**
-   * Adds cache contexts.
-   *
-   * @param string[] $cache_contexts
-   *   The cache contexts to be added.
-   *
-   * @return $this
-   */
-  public function addCacheContexts(array $cache_contexts) {
-    $this->contexts = Cache::mergeContexts($this->contexts, $cache_contexts);
-    return $this;
-  }
-
-  /**
-   * Sets cache contexts.
-   *
-   * @param string[] $cache_contexts
-   *   The cache contexts to be associated.
-   *
-   * @return $this
-   */
-  public function setCacheContexts(array $cache_contexts) {
-    $this->contexts = $cache_contexts;
-    return $this;
-  }
-
-  /**
-   * Gets the maximum age (in seconds).
-   *
-   * @return int
-   */
-  public function getCacheMaxAge() {
-    return $this->maxAge;
-  }
-
-  /**
-   * Sets the maximum age (in seconds).
-   *
-   * Defaults to Cache::PERMANENT
-   *
-   * @param int $max_age
-   *   The max age to associate.
-   *
-   * @return $this
-   *
-   * @throws \InvalidArgumentException
-   */
-  public function setCacheMaxAge($max_age) {
-    if (!is_int($max_age)) {
-      throw new \InvalidArgumentException('$max_age must be an integer');
-    }
-
-    $this->maxAge = $max_age;
-    return $this;
-  }
-
-  /**
    * Gets assets.
    *
    * @return array
diff --git a/core/modules/system/src/Controller/CsrfController.php b/core/modules/system/src/Controller/CsrfController.php
new file mode 100644
index 0000000..cd41b00
--- /dev/null
+++ b/core/modules/system/src/Controller/CsrfController.php
@@ -0,0 +1,64 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\system\Controller\CsrfController.
+ */
+
+namespace Drupal\system\Controller;
+
+use Drupal\Core\Access\CsrfTokenGenerator;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Responds to requests for a user CSRF tokens.
+ */
+class CsrfController implements ContainerInjectionInterface {
+
+  /**
+   * The token generator.
+   *
+   * @var \Drupal\Core\Access\CsrfTokenGenerator
+   */
+  protected $tokenGenerator;
+
+  /**
+   * Constructs a new CsrfController.
+   *
+   * @param \Drupal\Core\Access\CsrfTokenGenerator $token_generator
+   *   The token generator.
+   */
+  public function __construct(CsrfTokenGenerator $token_generator) {
+    $this->tokenGenerator = $token_generator;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('csrf_token')
+    );
+  }
+
+  /**
+   * Returns a JSON response containing CSRF tokens for a user.
+   *
+   * @return \Symfony\Component\HttpFoundation\JsonResponse
+   *   The JSON response.
+   */
+  public function tokens(Request $request) {
+    $data = [];
+    $groups  = (array) $request->query->get('groups', []);
+
+    foreach ($groups as $group) {
+      $data[$group] = $this->tokenGenerator->get($group);
+    }
+
+    return new JsonResponse($data);
+  }
+
+}
diff --git a/core/modules/system/system.routing.yml b/core/modules/system/system.routing.yml
index 39755b6..daf0b7e 100644
--- a/core/modules/system/system.routing.yml
+++ b/core/modules/system/system.routing.yml
@@ -474,3 +474,13 @@ system.entity_autocomplete:
     selection_settings: ''
   requirements:
     _access: 'TRUE'
+
+system.csrf_tokens:
+  path: '/csrf_tokens'
+  defaults:
+    _title: 'CSRF tokens'
+    _controller: '\Drupal\system\Controller\CsrfController::tokens'
+  requirements:
+    _user_is_logged_in: 'TRUE'
+  options:
+    _theme: ajax_base_page
diff --git a/core/tests/Drupal/Tests/Core/Access/RouteProcessorCsrfTest.php b/core/tests/Drupal/Tests/Core/Access/RouteProcessorCsrfTest.php
index 48f77b9..0f9b9fb 100644
--- a/core/tests/Drupal/Tests/Core/Access/RouteProcessorCsrfTest.php
+++ b/core/tests/Drupal/Tests/Core/Access/RouteProcessorCsrfTest.php
@@ -18,13 +18,6 @@
 class RouteProcessorCsrfTest extends UnitTestCase {
 
   /**
-   * The mock CSRF token generator.
-   *
-   * @var \Drupal\Core\Access\CsrfTokenGenerator|\PHPUnit_Framework_MockObject_MockObject
-   */
-  protected $csrfToken;
-
-  /**
    * The route processor.
    *
    * @var \Drupal\Core\Access\RouteProcessorCsrf
@@ -32,20 +25,13 @@ class RouteProcessorCsrfTest extends UnitTestCase {
   protected $processor;
 
   protected function setUp() {
-    $this->csrfToken = $this->getMockBuilder('Drupal\Core\Access\CsrfTokenGenerator')
-      ->disableOriginalConstructor()
-      ->getMock();
-
-    $this->processor = new RouteProcessorCsrf($this->csrfToken);
+    $this->processor = new RouteProcessorCsrf();
   }
 
   /**
  * Tests the processOutbound() method with no _csrf_token route requirement.
  */
   public function testProcessOutboundNoRequirement() {
-    $this->csrfToken->expects($this->never())
-      ->method('get');
-
     $route = new Route('/test-path');
     $parameters = array();
 
@@ -58,49 +44,42 @@ public function testProcessOutboundNoRequirement() {
    * Tests the processOutbound() method with a _csrf_token route requirement.
    */
   public function testProcessOutbound() {
-    $this->csrfToken->expects($this->once())
-      ->method('get')
-      // The leading '/' will be stripped from the path.
-      ->with('test-path')
-      ->will($this->returnValue('test_token'));
-
     $route = new Route('/test-path', array(), array('_csrf_token' => 'TRUE'));
     $parameters = array();
 
     $this->processor->processOutbound('test', $route, $parameters);
+
     // 'token' should be added to the parameters array.
     $this->assertArrayHasKey('token', $parameters);
-    $this->assertSame($parameters['token'], 'test_token');
+    $this->assertSame($parameters['token'], 'csrf_token_placeholder:TRUE');
   }
 
   /**
    * Tests the processOutbound() method with a dynamic path and one replacement.
    */
   public function testProcessOutboundDynamicOne() {
-    $this->csrfToken->expects($this->once())
-      ->method('get')
-      ->with('test-path/100')
-      ->will($this->returnValue('test_token'));
-
     $route = new Route('/test-path/{slug}', array(), array('_csrf_token' => 'TRUE'));
     $parameters = array('slug' => 100);
 
-    $this->assertNull($this->processor->processOutbound('test', $route, $parameters));
+    $this->processor->processOutbound('test', $route, $parameters);
+
+    // 'token' should be added to the parameters array.
+    $this->assertArrayHasKey('token', $parameters);
+    $this->assertSame($parameters['token'], 'csrf_token_placeholder:TRUE');
   }
 
   /**
    * Tests the processOutbound() method with two parameter replacements.
    */
   public function testProcessOutboundDynamicTwo() {
-    $this->csrfToken->expects($this->once())
-      ->method('get')
-      ->with('100/test-path/test')
-      ->will($this->returnValue('test_token'));
-
     $route = new Route('{slug_1}/test-path/{slug_2}', array(), array('_csrf_token' => 'TRUE'));
     $parameters = array('slug_1' => 100, 'slug_2' => 'test');
 
-    $this->assertNull($this->processor->processOutbound('test', $route, $parameters));
+    $this->processor->processOutbound('test', $route, $parameters);
+
+    // 'token' should be added to the parameters array.
+    $this->assertArrayHasKey('token', $parameters);
+    $this->assertSame($parameters['token'], 'csrf_token_placeholder:TRUE');
   }
 
 }
