diff --git a/cdn.install b/cdn.install
index 040f054..5924f94 100644
--- a/cdn.install
+++ b/cdn.install
@@ -29,3 +29,14 @@ function cdn_update_8001() {
 /**
  * @} End of "addtogroup updates-8.x-1.x-beta".
  */
+
+/**
+ * Update the default settings to include the stream_wrappers schema.
+ */
+function cdn_update_8002() {
+  $cdn_settings = \Drupal::configFactory()->getEditable('cdn.settings');
+  if (is_null($cdn_settings->get('stream_wrappers'))) {
+    $cdn_settings->set('stream_wrappers', []);
+    $cdn_settings->save();
+  }
+}
diff --git a/cdn.routing.yml b/cdn.routing.yml
index 7dec5ad..95d1b33 100644
--- a/cdn.routing.yml
+++ b/cdn.routing.yml
@@ -1,7 +1,10 @@
-cdn.farfuture.download:
-  path: '/cdn/farfuture/{security_token}/{mtime}'
+# The /cdn/farfuture route has been deprecated and is rewritten
+# in CdnFarfuturePathProcessor.
+cdn.farfuture_scheme.download:
+  path: '/cdn/ff/{security_token}/{mtime}/{scheme}'
   defaults:
     _controller: '\Drupal\cdn\CdnFarfutureController::download'
   requirements:
     _access: 'TRUE'
     mtime: \d+
+    scheme: '(:\w+:)|([a-zA-Z0-9+.-]+)'
diff --git a/cdn.services.yml b/cdn.services.yml
index f9b2ad3..2b5cc43 100644
--- a/cdn.services.yml
+++ b/cdn.services.yml
@@ -1,7 +1,7 @@
 services:
   cdn.settings:
     class: Drupal\cdn\CdnSettings
-    arguments: ['@config.factory']
+    arguments: ['@config.factory', '@stream_wrapper_manager']
 
   cdn.file_url_generator:
     class: Drupal\cdn\File\FileUrlGenerator
diff --git a/cdn_ui/js/summaries.js b/cdn_ui/js/summaries.js
index 64696be..0be8aa3 100644
--- a/cdn_ui/js/summaries.js
+++ b/cdn_ui/js/summaries.js
@@ -15,6 +15,15 @@
         return document.querySelector('input[name="status"]').checked ? Drupal.t('Enabled') : Drupal.t('Disabled');
       });
 
+      $('[data-drupal-selector="edit-wrappers"]').drupalSetSummary(function () {
+        var additional = $('[data-drupal-selector="edit-wrappers-stream-wrappers"] input:checked');
+        var wrappers = [];
+        additional.each(function(index) {
+          wrappers.push(this.getAttribute('value'));
+        });
+        return wrappers.join(', ');
+      });
+
       $('[data-drupal-selector="edit-mapping"]').drupalSetSummary(function () {
         if (document.querySelector('select[name="mapping[type]"]').value === 'simple') {
           var domain = document.querySelector('input[name="mapping[simple][domain]"]').value;
diff --git a/cdn_ui/src/Form/CdnSettingsForm.php b/cdn_ui/src/Form/CdnSettingsForm.php
index bd975a7..13b974a 100644
--- a/cdn_ui/src/Form/CdnSettingsForm.php
+++ b/cdn_ui/src/Form/CdnSettingsForm.php
@@ -3,8 +3,12 @@
 namespace Drupal\cdn_ui\Form;
 
 use Drupal\cdn\CdnSettings;
+use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\Form\ConfigFormBase;
 use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\StreamWrapper\StreamWrapperInterface;
+use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
  * Configure CDN settings for this site.
@@ -12,6 +16,31 @@ use Drupal\Core\Form\FormStateInterface;
 class CdnSettingsForm extends ConfigFormBase {
 
   /**
+   * The stream wrapper manager.
+   *
+   * @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
+   */
+  protected $streamWrapperManager;
+
+  /**
+   * @inheritDoc
+   */
+  public function __construct(ConfigFactoryInterface $config_factory, StreamWrapperManagerInterface $streamWrapperManager) {
+    parent::__construct($config_factory);
+    $this->streamWrapperManager = $streamWrapperManager;
+  }
+
+  /**
+   * @inheritDoc
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('config.factory'),
+      $container->get('stream_wrapper_manager')
+    );
+  }
+
+  /**
    * {@inheritdoc}
    */
   public function getFormId() {
@@ -74,7 +103,6 @@ class CdnSettingsForm extends ConfigFormBase {
       '#wrapper_attributes' => ['class' => ['container-inline']],
       '#attributes' => ['class' => ['container-inline']],
       '#default_value' => $config->get('mapping.type') === 'simple' ?: 'advanced',
-      '#attributes' => ['class' => ['container-inline']],
     ];
     $form['mapping']['simple'] = [
       '#type' => 'container',
@@ -143,6 +171,27 @@ class CdnSettingsForm extends ConfigFormBase {
       '#default_value' => $config->get('farfuture.status'),
     ];
 
+    $visibleWrappers = array_keys($this->streamWrapperManager
+      ->getWrappers(StreamWrapperInterface::VISIBLE));
+    $localWrappers = array_keys($this->streamWrapperManager
+      ->getWrappers(StreamWrapperInterface::LOCAL_NORMAL));
+    $wrappers = array_unique(array_merge($localWrappers, $visibleWrappers));
+    $existingWrappers = $config->get('stream_wrappers');
+    $form['wrappers'] = [
+      '#type' => 'details',
+      '#title' => $this->t('Stream wrappers'),
+      '#group' => 'cdn_settings',
+      '#tree' => TRUE,
+    ];
+    $form['wrappers']['stream_wrappers'] = [
+      '#type' => 'checkboxes',
+      '#options' => array_combine($wrappers, $wrappers),
+      '#default_value' => array_merge($localWrappers, $existingWrappers),
+      '#description' => $this->t('Stream wrappers to rewrite for CDN. "Local" stream wrappers are always enabled.')
+    ];
+    foreach ($localWrappers as $localWrapper) {
+      $form['wrappers']['stream_wrappers'][$localWrapper]['#disabled'] = TRUE;
+    }
     return parent::buildForm($form, $form_state);
   }
 
@@ -169,6 +218,9 @@ class CdnSettingsForm extends ConfigFormBase {
     // Vertical tab: 'Status'.
     $config->set('status', (bool) $form_state->getValue('status'));
 
+    // Vertical tab: 'Additional stream wrappers'
+    $config->set('stream_wrappers', array_values(array_filter($form_state->getValue(['wrappers', 'stream_wrappers']))));
+
     // Vertical tab: 'Mapping'.
     if ($form_state->getValue(['mapping', 'type']) === 'simple') {
       $simple_mapping = $form_state->getValue(['mapping', 'simple']);
diff --git a/config/install/cdn.settings.yml b/config/install/cdn.settings.yml
index 0ed3fc6..4a68d64 100644
--- a/config/install/cdn.settings.yml
+++ b/config/install/cdn.settings.yml
@@ -69,3 +69,6 @@ mapping:
 
 farfuture:
   status: true
+# All LOCAL_NORMAL wrappers are enabled by rule,
+# additional wrappers can be added.
+stream_wrappers: []
diff --git a/config/schema/cdn.schema.yml b/config/schema/cdn.schema.yml
index b0d9272..e543e5b 100644
--- a/config/schema/cdn.schema.yml
+++ b/config/schema/cdn.schema.yml
@@ -20,3 +20,8 @@ cdn.settings:
         status:
           label: 'Forever cacheable files — status'
           type: boolean
+    stream_wrappers:
+      label: 'Stream wrappers for CDN'
+      type: sequence
+      sequence:
+        type: string
diff --git a/src/CdnFarfutureController.php b/src/CdnFarfutureController.php
index 34bcb35..24e62a1 100644
--- a/src/CdnFarfutureController.php
+++ b/src/CdnFarfutureController.php
@@ -2,8 +2,10 @@
 
 namespace Drupal\cdn;
 
+use Drupal\cdn\File\FileUrlGenerator;
 use Drupal\Component\Utility\Crypt;
 use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\File\FileSystemInterface;
 use Drupal\Core\PrivateKey;
 use Drupal\Core\Site\Settings;
 use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -15,6 +17,13 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
 class CdnFarfutureController implements ContainerInjectionInterface {
 
   /**
+   * The app root.
+   *
+   * @var string
+   */
+  protected $root;
+
+  /**
    * The private key service.
    *
    * @var \Drupal\Core\PrivateKey
@@ -22,11 +31,20 @@ class CdnFarfutureController implements ContainerInjectionInterface {
   protected $privateKey;
 
   /**
+   * The file system service.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
+  /**
    * @param \Drupal\Core\PrivateKey $private_key
    *   The private key service.
    */
-  public function __construct(PrivateKey $private_key) {
+  public function __construct($root, PrivateKey $private_key, FileSystemInterface $fileSystem) {
+    $this->root = $root;
     $this->privateKey = $private_key;
+    $this->fileSystem = $fileSystem;
   }
 
   /**
@@ -34,7 +52,9 @@ class CdnFarfutureController implements ContainerInjectionInterface {
    */
   public static function create(ContainerInterface $container) {
     return new static(
-      $container->get('private_key')
+      $container->get('app.root'),
+      $container->get('private_key'),
+      $container->get('file_system')
     );
   }
 
@@ -50,6 +70,10 @@ class CdnFarfutureController implements ContainerInjectionInterface {
    *   example). See https://www.drupal.org/node/1441502.
    * @param int $mtime
    *   The file's mtime.
+   * @param string $scheme
+   *   The file's scheme.
+   * @param string $root_relative_file_url
+   *   The relative path from FileUrlGenerator::generate
    *
    * @returns \Symfony\Component\HttpFoundation\BinaryFileResponse
    *   The response that will efficiently send the requested file.
@@ -61,15 +85,18 @@ class CdnFarfutureController implements ContainerInjectionInterface {
    * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
    *   Thrown when an invalid security token is provided.
    */
-  public function download(Request $request, $security_token, $mtime) {
-    // Ensure \Drupal\cdn\PathProcessor\CdnFarfuturePathProcessor did its job.
-    if (!$request->query->has('root_relative_file_url')) {
+  public function download(Request $request, $security_token, $mtime, $scheme) {
+    // Validate the scheme early.
+    if ($scheme != FileUrlGenerator::RELATIVE && !$this->fileSystem->validScheme($scheme)) {
       throw new BadRequestHttpException();
     }
 
+    $path = $request->query->get('root_relative_file_url');
     // Validate security token.
-    $root_relative_file_url = $request->query->get('root_relative_file_url');
-    $calculated_token = Crypt::hmacBase64($mtime . $root_relative_file_url, $this->privateKey->get() . Settings::getHashSalt());
+    $uri = $scheme == FileUrlGenerator::RELATIVE
+      ? $path
+      : $scheme . ':/' . $path; // Path comes with a leading slash from the URL.
+    $calculated_token = Crypt::hmacBase64($mtime . $uri, $this->privateKey->get() . Settings::getHashSalt());
     if ($security_token !== $calculated_token) {
       throw new AccessDeniedHttpException('Invalid security token.');
     }
@@ -105,7 +132,10 @@ class CdnFarfutureController implements ContainerInjectionInterface {
       'Last-Modified' => 'Wed, 20 Jan 1988 04:20:42 GMT',
     ];
 
-    $response = new BinaryFileResponse(substr($root_relative_file_url, 1), 200, $farfuture_headers, TRUE, NULL, FALSE, FALSE);
+    if ($scheme == FileUrlGenerator::RELATIVE) {
+      $uri = $this->root . $uri;
+    }
+    $response = new BinaryFileResponse($uri, 200, $farfuture_headers, TRUE, NULL, FALSE, FALSE);
     $response->isNotModified($request);
     return $response;
   }
diff --git a/src/CdnSettings.php b/src/CdnSettings.php
index f232c10..784effc 100644
--- a/src/CdnSettings.php
+++ b/src/CdnSettings.php
@@ -2,8 +2,11 @@
 
 namespace Drupal\cdn;
 
+use Drupal\Component\Utility\Unicode;
 use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\Config\ConfigValueException;
+use Drupal\Core\StreamWrapper\StreamWrapperInterface;
+use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
 
 /**
  * Wraps the CDN settings configuration, contains all parsing.
@@ -27,14 +30,22 @@ class CdnSettings {
   protected $lookupTable;
 
   /**
+   * The stream wrapper manager.
+   *
+   * @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
+   */
+  protected $streamWrapperManager;
+
+  /**
    * Constructs a new CdnSettings object.
    *
    * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
    *   The config factory.
    */
-  public function __construct(ConfigFactoryInterface $config_factory) {
+  public function __construct(ConfigFactoryInterface $config_factory, StreamWrapperManagerInterface $streamWrapperManager) {
     $this->rawSettings = $config_factory->get('cdn.settings');
     $this->lookupTable = NULL;
+    $this->streamWrapperManager = $streamWrapperManager;
   }
 
   /**
@@ -78,6 +89,18 @@ class CdnSettings {
   }
 
   /**
+   * Returns configured stream wrappers to apply CDN to.
+   *
+   * @return array
+   */
+  public function streamWrappers() {
+    $configured = $this->rawSettings->get('stream_wrappers');
+    // LOCAL_NORMAL stream wrappers are always served.
+    return array_merge(array_keys($this->streamWrapperManager
+      ->getWrappers(StreamWrapperInterface::LOCAL_NORMAL)), $configured);
+  }
+
+  /**
    * Builds a lookup table: file extension to CDN domain(s).
    *
    * @param array $mapping
@@ -175,4 +198,46 @@ class CdnSettings {
     return $components === FALSE ? FALSE : empty(array_intersect($forbidden_components, array_keys($components)));
   }
 
+  /**
+   * Maps a URI to a CDN domain.
+   *
+   * @param string $uri
+   *   The URI to map.
+   *
+   * @return string|bool
+   *   The mapped domain, or FALSE if it could not be matched.
+   */
+  public function getCdnDomain($uri) {
+    // Extension-specific mapping.
+    $file_extension = Unicode::strtolower(pathinfo($uri, PATHINFO_EXTENSION));
+    $lookup_table = $this->getLookupTable();
+    if (isset($lookup_table[$file_extension])) {
+      $key = $file_extension;
+    }
+    // Generic or fallback mapping.
+    elseif (isset($lookup_table['*'])) {
+      $key = '*';
+    }
+    // No mapping.
+    else {
+      return FALSE;
+    }
+
+    $result = $lookup_table[$key];
+
+    if ($result === FALSE) {
+      return FALSE;
+    }
+    // If there are multiple results, pick one using consistent hashing: ensure
+    // the same file is always served from the same CDN domain.
+    elseif (is_array($result)) {
+      $filename = basename($uri);
+      $hash = hexdec(substr(md5($filename), 0, 5));
+      $cdn_domain = $result[$hash % count($result)];
+    }
+    else {
+      $cdn_domain = $result;
+    }
+    return $cdn_domain;
+  }
 }
diff --git a/src/File/FileUrlGenerator.php b/src/File/FileUrlGenerator.php
index 88dc1e6..58929ca 100644
--- a/src/File/FileUrlGenerator.php
+++ b/src/File/FileUrlGenerator.php
@@ -8,7 +8,6 @@ use Drupal\Component\Utility\Unicode;
 use Drupal\Core\File\FileSystemInterface;
 use Drupal\Core\PrivateKey;
 use Drupal\Core\Site\Settings;
-use Drupal\Core\StreamWrapper\StreamWrapperInterface;
 use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
 use Symfony\Component\HttpFoundation\RequestStack;
 
@@ -19,6 +18,8 @@ use Symfony\Component\HttpFoundation\RequestStack;
  */
 class FileUrlGenerator {
 
+  const RELATIVE = ':relative:';
+
   /**
    * The app root.
    *
@@ -111,78 +112,61 @@ class FileUrlGenerator {
       return FALSE;
     }
 
-    $relative_url = $this->getRelativeUrl($uri);
-    if ($relative_url === FALSE) {
+    if (!$this->canServe($uri)) {
       return FALSE;
     }
 
-    // Extension-specific mapping.
-    $file_extension = Unicode::strtolower(pathinfo($uri, PATHINFO_EXTENSION));
-    $lookup_table = $this->settings->getLookupTable();
-    if (isset($lookup_table[$file_extension])) {
-      $key = $file_extension;
-    }
-    // Generic or fallback mapping.
-    elseif (isset($lookup_table['*'])) {
-      $key = '*';
-    }
-    // No mapping.
-    else {
+    if (!$cdn_domain = $this->settings->getCdnDomain($uri)) {
       return FALSE;
     }
 
-    $result = $lookup_table[$key];
-
-    if ($result === FALSE) {
-      return FALSE;
-    }
-    // If there are multiple results, pick one using consistent hashing: ensure
-    // the same file is always served from the same CDN domain.
-    elseif (is_array($result)) {
-      $filename = basename($uri);
-      $hash = hexdec(substr(md5($filename), 0, 5));
-      $cdn_domain = $result[$hash % count($result)];
+    // When farfuture is enabled, rewrite the file URL to let Drupal serve the
+    // file with optimal headers. Only possible if the file exists.
+    if (!$scheme = $this->fileSystem->uriScheme($uri)) {
+      $scheme = self::RELATIVE;
+      $fileUri = $filePath = $relative = '/' . $uri;
+      $realFile = $this->root . $fileUri;
     }
     else {
-      $cdn_domain = $result;
+      $fileUri = $realFile = $uri;
+      $filePath = substr($fileUri, strlen($scheme . ':/')); // Leading slash.
+      $relative = str_replace($this->requestStack->getCurrentRequest()->getSchemeAndHttpHost() . $this->getBasePath(), '', $this->streamWrapperManager->getViaUri($uri)->getExternalUrl());
     }
-
-    // When farfuture is enabled, rewrite the file URL to let Drupal serve the
-    // file with optimal headers. Only possible if the file exists.
-    $absolute_file_path = $this->root . $relative_url;
-    if ($this->settings->farfutureIsEnabled() && file_exists($absolute_file_path)) {
+    if ($this->settings->farfutureIsEnabled() && file_exists($realFile)) {
       // We do the filemtime() call separately, because a failed filemtime()
       // will cause a PHP warning to be written to the log, which would remove
       // any performance gain achieved by removing the file_exists() call.
-      $mtime = filemtime($absolute_file_path);
+      $mtime = filemtime($realFile);
 
       // Generate a security token. Ensures that users can not request any file
       // they want by manipulating the URL (they could otherwise request
       // settings.php for example). See https://www.drupal.org/node/1441502.
-      $calculated_token = Crypt::hmacBase64($mtime . $relative_url, $this->privateKey->get() . Settings::getHashSalt());
-      return '//' . $cdn_domain . $this->getBasePath() . '/cdn/farfuture/' . $calculated_token . '/' . $mtime . $relative_url;
+      $calculated_token = Crypt::hmacBase64($mtime . $fileUri, $this->privateKey->get() . Settings::getHashSalt());
+      return '//' . $cdn_domain . $this->getBasePath() . '/cdn/ff/' . $calculated_token . '/' . $mtime . '/' . $scheme . $filePath;
     }
 
-    return '//' . $cdn_domain . $this->getBasePath() . $relative_url;
+    return '//' . $cdn_domain . $this->getBasePath() . $relative;
   }
 
   /**
-   * Gets the relative URL for files that are shipped or in a local stream.
+   * Determines if a URI can/should be served by CDN.
    *
    * @param string $uri
    *   The URI to a file for which we need a CDN URL, or the path to a shipped
    *   file.
    *
-   * @return bool|string
-   *   Returns FALSE if the URI is not for a shipped file or in a local stream.
-   *   Otherwise, returns the relative URL.
+   * @return bool
+   *   Returns FALSE if the URI is not for a shipped file or in an eligible stream.
+   *   TRUE otherwise.
    */
-  protected function getRelativeUrl($uri) {
+  protected function canServe($uri) {
     $scheme = $this->fileSystem->uriScheme($uri);
 
+    // Allow additional stream wrappers to be served via CDN.
+    $streamWrapperTypes = $this->settings->streamWrappers();
     // If the URI is absolute — HTTP(S) or otherwise — return early, except if
-    // it's an absolute URI using a local stream wrapper scheme.
-    if ($scheme && !isset($this->streamWrapperManager->getWrappers(StreamWrapperInterface::LOCAL)[$scheme])) {
+    // it's an absolute URI using an approved stream wrapper type.
+    if ($scheme && !in_array($scheme, $streamWrapperTypes)) {
       return FALSE;
     }
     // If the URI is protocol-relative, return early.
@@ -193,14 +177,7 @@ class FileUrlGenerator {
     elseif ($scheme === 'private') {
       return FALSE;
     }
-
-    $request = $this->requestStack->getCurrentRequest();
-
-    return $scheme
-      // Local stream wrapper.
-      ? str_replace($request->getSchemeAndHttpHost() . $this->getBasePath(), '', $this->streamWrapperManager->getViaUri($uri)->getExternalUrl())
-      // Shipped file.
-      : '/' . $uri;
+    return TRUE;
   }
 
   /**
diff --git a/src/PathProcessor/CdnFarfuturePathProcessor.php b/src/PathProcessor/CdnFarfuturePathProcessor.php
index 4704d00..6f3508d 100644
--- a/src/PathProcessor/CdnFarfuturePathProcessor.php
+++ b/src/PathProcessor/CdnFarfuturePathProcessor.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\cdn\PathProcessor;
 
+use Drupal\cdn\File\FileUrlGenerator;
 use Drupal\Core\PathProcessor\InboundPathProcessorInterface;
 use Symfony\Component\HttpFoundation\Request;
 
@@ -11,6 +12,9 @@ use Symfony\Component\HttpFoundation\Request;
  * As the route system does not allow arbitrary amount of parameters convert
  * the file path to a query parameter on the request.
  *
+ * Also normalizes legacy far-future URLs generated prior to
+ * https://www.drupal.org/node/2870435
+ *
  * @see \Drupal\image\PathProcessor\PathProcessorImageStyles
  */
 class CdnFarfuturePathProcessor implements InboundPathProcessorInterface {
@@ -19,19 +23,30 @@ class CdnFarfuturePathProcessor implements InboundPathProcessorInterface {
    * {@inheritdoc}
    */
   public function processInbound($path, Request $request) {
-    if (strpos($path, '/cdn/farfuture/') !== 0) {
+    if (!preg_match('/^\/cdn\/(ff|farfuture)\/.*/', $path, $matches)) {
       return $path;
     }
 
-    // Parse the security token, mtime and root-relative file URL.
-    $tail = substr($path, strlen('/cdn/farfuture/'));
-    list($security_token, $mtime, $root_relative_file_url) = explode('/', $tail, 3);
+    // Backwards compatibility for non-scheme aware farfuture paths.
+    if ($matches[1] == 'farfuture') {
+      // Normalize legacy path.
+      // Parse the security token, mtime and root-relative file URL.
+      $tail = substr($path, strlen('/cdn/farfuture/'));
+      list($security_token, $mtime, $root_relative_file_url) = explode('/', $tail, 3);
+      $returnPath = "/cdn/ff/$security_token/$mtime/" . FileUrlGenerator::RELATIVE;
+    }
+    else {
+      // Parse the security token, mtime, scheme and root-relative file URL.
+      $tail = substr($path, strlen('/cdn/ff/'));
+      list($security_token, $mtime, $scheme, $root_relative_file_url) = explode('/', $tail, 4);
+      $returnPath = "/cdn/ff/$security_token/$mtime/$scheme";
+    }
 
     // Set the root-relative file URL as query parameter.
     $request->query->set('root_relative_file_url', '/' . $root_relative_file_url);
 
     // Return the same path, but without the trailing file.
-    return "/cdn/farfuture/$security_token/$mtime";
+    return $returnPath;
   }
 
 }
diff --git a/tests/src/Functional/CdnIntegrationTest.php b/tests/src/Functional/CdnIntegrationTest.php
index fde6f3a..89fe79c 100644
--- a/tests/src/Functional/CdnIntegrationTest.php
+++ b/tests/src/Functional/CdnIntegrationTest.php
@@ -97,7 +97,7 @@ class CdnIntegrationTest extends BrowserTestBase {
     $this->drupalGet('<front>');
     $this->assertSame('MISS', $session->getResponseHeader('X-Drupal-Cache'), 'Changing CDN settings causes Page Cache miss: setting changes have immediate effect.');
     $href = $this->cssSelect('link[rel=stylesheet]')[0]->getAttribute('href');
-    $regexp = '#/' . $this->siteDirectory . '/files/css/css_[a-zA-Z0-9_]{43}\.css\?[a-z0-9]{6}#';
+    $regexp = '#/' . $this->siteDirectory . '/files/css/css_[a-zA-Z0-9_-]{43}\.css\?[a-z0-9]{6}#';
     $this->assertSame(1, preg_match($regexp, $href));
     $this->assertCssFileUsesRootRelativeUrl($this->baseUrl . $href);
 
@@ -106,7 +106,7 @@ class CdnIntegrationTest extends BrowserTestBase {
     $this->drupalGet('<front>');
     $this->assertSame('MISS', $session->getResponseHeader('X-Drupal-Cache'), 'Changing CDN settings causes Page Cache miss: setting changes have immediate effect.');
     $href = $this->cssSelect('link[rel=stylesheet]')[0]->getAttribute('href');
-    $regexp = '#//cdn.example.com' . base_path() . $this->siteDirectory . '/files/css/css_[a-zA-Z0-9_]{43}\.css\?[a-z0-9]{6}#';
+    $regexp = '#//cdn.example.com' . base_path() . $this->siteDirectory . '/files/css/css_[a-zA-Z0-9_-]{43}\.css\?[a-z0-9]{6}#';
     $this->assertSame(1, preg_match($regexp, $href));
     $this->assertCssFileUsesRootRelativeUrl($this->baseUrl . str_replace('//cdn.example.com', '', $href));
 
@@ -115,7 +115,7 @@ class CdnIntegrationTest extends BrowserTestBase {
     $this->drupalGet('<front>');
     $this->assertSame('MISS', $session->getResponseHeader('X-Drupal-Cache'), 'Changing CDN settings causes Page Cache miss: setting changes have immediate effect.');
     $href = $this->cssSelect('link[rel=stylesheet]')[0]->getAttribute('href');
-    $regexp = '#//cdn.example.com' . base_path() . 'cdn/farfuture/[a-zA-Z0-9_-]{43}/[0-9]{10}/' . $this->siteDirectory . '/files/css/css_[a-zA-Z0-9_]{43}\.css\?[a-z0-9]{6}#';
+    $regexp = '#//cdn.example.com' . base_path() . 'cdn/ff/[a-zA-Z0-9_-]{43}/[0-9]{10}/public/css/css_[a-zA-Z0-9_-]{43}\.css\?[a-z0-9]{6}#';
     $this->assertSame(1, preg_match($regexp, $href));
     $this->assertCssFileUsesRootRelativeUrl($this->baseUrl . str_replace('//cdn.example.com', '', $href));
   }
@@ -159,6 +159,9 @@ class CdnIntegrationTest extends BrowserTestBase {
     $drupal_js_mtime = filemtime(DRUPAL_ROOT . '/core/misc/drupal.js');
     $drupal_js_security_token = Crypt::hmacBase64($drupal_js_mtime . '/core/misc/drupal.js', \Drupal::service('private_key')->get() . Settings::getHashSalt());
 
+    $this->drupalGet('/cdn/ff/' . $drupal_js_security_token . '/' . $drupal_js_mtime . '/:relative:/core/misc/drupal.js');
+    $this->assertSession()->statusCodeEquals(200);
+    // Test backwards-compatible path for relative assets.
     $this->drupalGet('/cdn/farfuture/' . $drupal_js_security_token . '/' . $drupal_js_mtime . '/core/misc/drupal.js');
     $this->assertSession()->statusCodeEquals(200);
     // Assert presence of headers that \Drupal\cdn\CdnFarfutureController sets.
@@ -166,8 +169,8 @@ class CdnIntegrationTest extends BrowserTestBase {
     // Assert presence of headers that Symfony's BinaryFileResponse sets.
     $this->assertSame('bytes', $this->getSession()->getResponseHeader('Accept-Ranges'));
 
-    // Any chance to the security token should cause a 403.
-    $this->drupalGet('/cdn/farfuture/' . substr($drupal_js_security_token, 1) . '/' . $drupal_js_mtime . '/core/misc/drupal.js');
+    // Any change to the security token should cause a 403.
+    $this->drupalGet('/cdn/ff/' . substr($drupal_js_security_token, 1) . '/' . $drupal_js_mtime . '/:relative:/core/misc/drupal.js');
     $this->assertSession()->statusCodeEquals(403);
   }
 
diff --git a/tests/src/Unit/CdnSettingsTest.php b/tests/src/Unit/CdnSettingsTest.php
index 1ea53e5..4abbd5c 100644
--- a/tests/src/Unit/CdnSettingsTest.php
+++ b/tests/src/Unit/CdnSettingsTest.php
@@ -3,6 +3,7 @@
 namespace Drupal\Tests\cdn\Unit;
 
 use Drupal\cdn\CdnSettings;
+use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
 use Drupal\Tests\UnitTestCase;
 
 /**
@@ -449,7 +450,8 @@ class CdnSettingsTest extends UnitTestCase {
    *   The CdnSettings object to test.
    */
   protected function createCdnSettings(array $raw_config) {
-    return new CdnSettings($this->getConfigFactoryStub(['cdn.settings' => $raw_config]));
+    $stream_wrapper_manager = $this->prophesize(StreamWrapperManagerInterface::class);
+    return new CdnSettings($this->getConfigFactoryStub(['cdn.settings' => $raw_config]), $stream_wrapper_manager->reveal());
   }
 
 }
diff --git a/tests/src/Unit/File/FileUrlGeneratorTest.php b/tests/src/Unit/File/FileUrlGeneratorTest.php
index 073ec58..9ada8d1 100644
--- a/tests/src/Unit/File/FileUrlGeneratorTest.php
+++ b/tests/src/Unit/File/FileUrlGeneratorTest.php
@@ -67,10 +67,11 @@ class FileUrlGeneratorTest extends UnitTestCase {
             ],
           ],
         ],
-        'farfuture' => [
-          'status' => FALSE,
-        ],
       ],
+      'farfuture' => [
+        'status' => FALSE,
+      ],
+      'stream_wrappers' => [],
     ]);
     $this->assertSame($expected_result, $gen->generate($uri));
   }
@@ -133,6 +134,7 @@ class FileUrlGeneratorTest extends UnitTestCase {
       'farfuture' => [
         'status' => TRUE,
       ],
+      'stream_wrappers' => [],
     ];
 
     // In root.
@@ -140,14 +142,14 @@ class FileUrlGeneratorTest extends UnitTestCase {
     $this->assertSame('//cdn.example.com/core/misc/does-not-exist.js', $gen->generate('core/misc/does-not-exist.js'));
     $drupal_js_mtime = filemtime($this->root . '/core/misc/drupal.js');
     $drupal_js_security_token = Crypt::hmacBase64($drupal_js_mtime . '/core/misc/drupal.js', static::$privateKey . Settings::getHashSalt());
-    $this->assertSame('//cdn.example.com/cdn/farfuture/' . $drupal_js_security_token . '/' . $drupal_js_mtime . '/core/misc/drupal.js', $gen->generate('core/misc/drupal.js'));
+    $this->assertSame('//cdn.example.com/cdn/ff/' . $drupal_js_security_token . '/' . $drupal_js_mtime . '/:relative:/core/misc/drupal.js', $gen->generate('core/misc/drupal.js'));
 
     // In subdir.
     $gen = $this->createFileUrlGenerator('/subdir', $config);
     $this->assertSame('//cdn.example.com/subdir/core/misc/does-not-exist.js', $gen->generate('core/misc/does-not-exist.js'));
     $drupal_js_mtime = filemtime($this->root . '/core/misc/drupal.js');
     $drupal_js_security_token = Crypt::hmacBase64($drupal_js_mtime . '/core/misc/drupal.js', static::$privateKey . Settings::getHashSalt());
-    $this->assertSame('//cdn.example.com/subdir/cdn/farfuture/' . $drupal_js_security_token . '/' . $drupal_js_mtime . '/core/misc/drupal.js', $gen->generate('core/misc/drupal.js'));
+    $this->assertSame('//cdn.example.com/subdir/cdn/ff/' . $drupal_js_security_token . '/' . $drupal_js_mtime . '/:relative:/core/misc/drupal.js', $gen->generate('core/misc/drupal.js'));
   }
 
   /**
@@ -182,8 +184,8 @@ class FileUrlGeneratorTest extends UnitTestCase {
         return 'http://example.com' . $base_path . '/sites/default/files/' . substr($current_uri, 9);
       });
     $stream_wrapper_manager = $this->prophesize(StreamWrapperManagerInterface::class);
-    $stream_wrapper_manager->getWrappers(StreamWrapperInterface::LOCAL)
-      ->willReturn(['public' => TRUE, 'private' => TRUE]);
+    $stream_wrapper_manager->getWrappers(StreamWrapperInterface::LOCAL_NORMAL)
+      ->willReturn(['public' => TRUE]);
     $stream_wrapper_manager->getViaUri(Argument::that(function ($uri) {
       return substr($uri, 0, 9) === 'public://';
     }))
@@ -206,7 +208,7 @@ class FileUrlGeneratorTest extends UnitTestCase {
       $stream_wrapper_manager->reveal(),
       $request_stack->reveal(),
       $private_key->reveal(),
-      new CdnSettings($this->getConfigFactoryStub(['cdn.settings' => $raw_config]))
+      new CdnSettings($this->getConfigFactoryStub(['cdn.settings' => $raw_config]), $stream_wrapper_manager->reveal())
     );
   }
 
