diff --git a/README.md b/README.md
index edbb59c..f961eac 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,9 @@ $schemes = [
 
       // 'endpoint' => 'https://api.example.com', // An alternative API endpoint
                                                   // for 3rd party S3 providers.
+      // 'expires' => '+600 seconds',             // If a file is private, the
+                                                  // expiry time of a presigned
+                                                  // URL.
     ],
 
     'cache' => TRUE, // Creates a metadata cache to speed up lookups.
diff --git a/composer.json b/composer.json
index 4d8036d..c210a95 100644
--- a/composer.json
+++ b/composer.json
@@ -3,5 +3,8 @@
   "require": {
     "league/flysystem": "^1.0.20",
     "league/flysystem-aws-s3-v3": "^1.0, !=1.0.12, !=1.0.13"
+  },
+  "require-dev": {
+    "mikey179/vfsStream": "^1.6"
   }
 }
diff --git a/flysystem_s3.services.yml b/flysystem_s3.services.yml
new file mode 100644
index 0000000..952098e
--- /dev/null
+++ b/flysystem_s3.services.yml
@@ -0,0 +1,6 @@
+services:
+  flysystem_s3.file_system:
+    public: false
+    class: \Drupal\flysystem_s3\File\FileSystem
+    decorates: file_system
+    arguments: ['@settings', '@flysystem_s3.file_system.inner']
diff --git a/src/File/FileSystem.php b/src/File/FileSystem.php
new file mode 100644
index 0000000..b5ac99c
--- /dev/null
+++ b/src/File/FileSystem.php
@@ -0,0 +1,187 @@
+<?php
+
+namespace Drupal\flysystem_s3\File;
+
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Site\Settings;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Decorates the Drupal FileSystem service to handle chmod() for S3.
+ */
+class FileSystem implements FileSystemInterface {
+
+  /**
+   * The site settings.
+   *
+   * @var \Drupal\Core\Site\Settings
+   */
+  protected $settings;
+
+  /**
+   * The file system being decorated.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
+  /**
+   * FileSystem constructor.
+   *
+   * @param \Drupal\Core\Site\Settings $settings
+   *   The site settings.
+   * @param \Drupal\Core\File\FileSystemInterface $file_system
+   *   The file system being decorated.
+   */
+  public function __construct(Settings $settings, FileSystemInterface $file_system) {
+    $this->settings = $settings;
+    $this->fileSystem = $file_system;
+  }
+
+  /**
+   * Implement chmod(), respecting S3's ACL setting.
+   *
+   * With Drupal's private files, chmod() is called by file_save_upload() to
+   * ensure the new file is readable by the file server, using the same file
+   * system permissions as the public file system. However, since private files
+   * are stored outside of the docroot, they are forced to go be accessed
+   * through Drupal's file permissions handling.
+   *
+   * With S3, \Twistor\FlysystemStreamWrapper::stream_metadata() automatically
+   * maps chmod() calls to basic S3 ACLs, which means that while a file can be
+   * initially uploaded as 'private', Drupal will immediately chmod it to
+   * public using the default file mask in settings.php.
+   *
+   * This method checks to see if we are using a private S3 scheme, and if so,
+   * ensures that group / other permissions are always unset, ensuring the
+   * stream wrapper preserves private permissions.
+   *
+   * @param string $uri
+   *   A string containing a URI file, or directory path.
+   * @param int $mode
+   *   Integer value for the permissions. Consult PHP chmod() documentation for
+   *   more information.
+   *
+   * @return bool
+   *   TRUE for success, FALSE in the event of an error.
+   *
+   * @see \Twistor\FlysystemStreamWrapper::stream_metadata
+   */
+  public function chmod($uri, $mode = NULL) {
+    $scheme = $this->fileSystem->uriScheme($uri);
+
+    if ($this->isPrivateS3Scheme($scheme)) {
+      is_dir($uri) ? $mode = 0700 : $mode = 0600;
+    }
+
+    return $this->fileSystem->chmod($uri, $mode);
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @codeCoverageIgnore
+   */
+  public function moveUploadedFile($filename, $uri) {
+    return $this->fileSystem->moveUploadedFile($filename, $uri);
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @codeCoverageIgnore
+   */
+  public function unlink($uri, $context = NULL) {
+    return $this->fileSystem->unlink($uri, $context);
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @codeCoverageIgnore
+   */
+  public function realpath($uri) {
+    return $this->fileSystem->realpath($uri);
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @codeCoverageIgnore
+   */
+  public function dirname($uri) {
+    return $this->fileSystem->dirname($uri);
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @codeCoverageIgnore
+   */
+  public function basename($uri, $suffix = NULL) {
+    return $this->fileSystem->basename($uri, $suffix);
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @codeCoverageIgnore
+   */
+  public function mkdir($uri, $mode = NULL, $recursive = FALSE, $context = NULL) {
+    return $this->fileSystem->mkdir($uri, $mode, $recursive, $context);
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @codeCoverageIgnore
+   */
+  public function rmdir($uri, $context = NULL) {
+    return $this->fileSystem->rmdir($uri, $context);
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @codeCoverageIgnore
+   */
+  public function tempnam($directory, $prefix) {
+    return $this->fileSystem->tempnam($directory, $prefix);
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @codeCoverageIgnore
+   */
+  public function uriScheme($uri) {
+    return $this->fileSystem->uriScheme($uri);
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @codeCoverageIgnore
+   */
+  public function validScheme($scheme) {
+    return $this->fileSystem->validScheme($scheme);
+  }
+
+  /**
+   * Return if a scheme is a private S3 scheme.
+   *
+   * @param string $scheme
+   *   The scheme to check.
+   *
+   * @return bool
+   *   TRUE if the scheme's S3 acl is set to 'private'.
+   */
+  protected function isPrivateS3Scheme($scheme) {
+    $settings = $this->settings->get('flysystem', []);
+    return isset($settings[$scheme])
+      && $settings[$scheme]['driver'] == 's3'
+      && isset($settings[$scheme]['config']['options']['ACL'])
+      && $settings[$scheme]['config']['options']['ACL'] == 'private';
+  }
+
+}
diff --git a/src/Flysystem/S3.php b/src/Flysystem/S3.php
index f8a80a7..d3953b2 100644
--- a/src/Flysystem/S3.php
+++ b/src/Flysystem/S3.php
@@ -7,14 +7,18 @@ use Aws\Credentials\Credentials;
 use Aws\S3\S3Client;
 use Drupal\Component\Utility\UrlHelper;
 use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\Core\PageCache\ResponsePolicy\KillSwitch;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Render\RendererInterface;
 use Drupal\flysystem\Plugin\FlysystemPluginInterface;
 use Drupal\flysystem\Plugin\FlysystemUrlTrait;
 use Drupal\flysystem\Plugin\ImageStyleGenerationTrait;
 use Drupal\flysystem_s3\AwsCacheAdapter;
 use Drupal\flysystem_s3\Flysystem\Adapter\S3Adapter;
+use League\Flysystem\AdapterInterface;
 use League\Flysystem\Config;
 use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
 
 /**
  * Drupal plugin for the "S3" Flysystem adapter.
@@ -36,7 +40,7 @@ class S3 implements FlysystemPluginInterface, ContainerFactoryPluginInterface {
   /**
    * The S3 client.
    *
-   * @var \Aws\AwsClientInterface
+   * @var \Aws\S3\S3ClientInterface
    */
   protected $client;
 
@@ -62,33 +66,85 @@ class S3 implements FlysystemPluginInterface, ContainerFactoryPluginInterface {
   protected $urlPrefix;
 
   /**
+   * The amount of time presigned URLs are valid for, such as '+60 seconds'.
+   *
+   * @var string
+   */
+  protected $expires;
+
+  /**
+   * The Drupal renderer used to set cache expiration.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * The kill switch response policy.
+   *
+   * @var \Drupal\Core\PageCache\ResponsePolicy\KillSwitch
+   */
+  protected $killSwitch;
+
+  /**
    * Constructs a S3v3 object.
    *
    * @param \Aws\AwsClientInterface $client
    *   The AWS client.
    * @param \League\Flysystem\Config $config
    *   The configuration.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The Drupal renderer used to set cache expiration.
+   * @param \Drupal\Core\PageCache\ResponsePolicy\KillSwitch $kill_switch
+   *   (optional) Service to disable page caching when presigned URLs are used.
    */
-  public function __construct(AwsClientInterface $client, Config $config) {
+  public function __construct(AwsClientInterface $client, Config $config, RendererInterface $renderer, KillSwitch $kill_switch = NULL) {
     $this->client = $client;
     $this->bucket = $config->get('bucket', '');
     $this->prefix = $config->get('prefix', '');
     $this->options = $config->get('options', []);
+    $this->expires = $config->get('expires');
 
     $this->urlPrefix = $this->calculateUrlPrefix($config);
+
+    $this->renderer = $renderer;
+    $this->killSwitch = $kill_switch;
   }
 
   /**
    * {@inheritdoc}
    */
   public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
-    $protocol = $container->get('request_stack')->getCurrentRequest()->getScheme();
-    $configuration += [
-      'protocol' => $protocol,
-      'region' => 'us-east-1',
-      'endpoint' => NULL,
-    ];
+    $configuration = self::mergeConfiguration($container, $configuration);
+    $client_config = self::mergeClientConfiguration($container, $configuration);
+
+    $client = new S3Client($client_config);
+
+    unset($configuration['key'], $configuration['secret']);
+
+    try {
+      $kill_switch = $container->get('page_cache_kill_switch');
+    }
+    catch (ServiceNotFoundException $e) {
+      // The page cache module is not installed.
+      $kill_switch = NULL;
+    }
+
+    return new static($client, new Config($configuration), $container->get('renderer'), $kill_switch);
+  }
 
+  /**
+   * Return an S3 client configuration based on a Flysystem configuration.
+   *
+   * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
+   *   The container to pull out services used in the plugin.
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   *
+   * @return array
+   *   The client configuration.
+   */
+  public static function mergeClientConfiguration(ContainerInterface $container, array $configuration) {
     $client_config = [
       'version' => 'latest',
       'region' => $configuration['region'],
@@ -106,11 +162,30 @@ class S3 implements FlysystemPluginInterface, ContainerFactoryPluginInterface {
       );
     }
 
-    $client = new S3Client($client_config);
-
-    unset($configuration['key'], $configuration['secret']);
+    return $client_config;
+  }
 
-    return new static($client, new Config($configuration));
+  /**
+   * Merge default Flysystem configuration.
+   *
+   * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
+   *   The container to pull out services used in the plugin.
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   *
+   * @return array
+   *   The Flysystem configuration.
+   */
+  public static function mergeConfiguration(ContainerInterface $container, array $configuration) {
+    $protocol = $container->get('request_stack')
+      ->getCurrentRequest()
+      ->getScheme();
+    $configuration += [
+      'protocol' => $protocol,
+      'region' => 'us-east-1',
+      'endpoint' => NULL,
+    ];
+    return $configuration;
   }
 
   /**
@@ -130,6 +205,38 @@ class S3 implements FlysystemPluginInterface, ContainerFactoryPluginInterface {
       $this->generateImageStyle($target);
     }
 
+    if ($this->getAdapter()->getVisibility($target)['visibility'] == AdapterInterface::VISIBILITY_PRIVATE && $this->expires) {
+      // Use getCommand() so we don't actually make a request yet.
+      $command = $this->client->getCommand('getObject', [
+        'Bucket' => $this->bucket,
+        'Key' => $target,
+      ]);
+      $request = $this->client->createPresignedRequest($command, $this->expires);
+
+      // This informs the render system that the request has a cache dependency
+      // on the time this URL is valid for.
+      // TODO: The page cache does not currently respect max-age cache headers.
+      // We can't set proper max-age based on the signing time until
+      // https://www.drupal.org/node/2352009 is fixed. Unfortunately, this also
+      // means we can't cache any pages with signed URLs at all. When we
+      // can implement this, we should parse out max-age from the generated URL
+      // as suggested at https://github.com/aws/aws-sdk-php/issues/1052.
+      $build = [
+        '#cache' => [
+          'max-age' => 0,
+        ],
+      ];
+      $this->renderer->render($build);
+
+      // Since the above bug means this max-age isn't respected, we have to
+      // kill the general page cache.
+      if ($this->killSwitch) {
+        $this->killSwitch->trigger();
+      }
+
+      return (string) $request->getUri();
+    }
+
     return $this->urlPrefix . '/' . UrlHelper::encodePath($target);
   }
 
diff --git a/tests/src/Unit/File/FileSystemTest.php b/tests/src/Unit/File/FileSystemTest.php
new file mode 100644
index 0000000..b7228f1
--- /dev/null
+++ b/tests/src/Unit/File/FileSystemTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace NoDrupal\Tests\flysystem_s3\Unit\File;
+
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Site\Settings;
+use Drupal\flysystem_s3\File\FileSystem;
+use Drupal\Tests\UnitTestCase;
+use org\bovigo\vfs\vfsStream;
+use Prophecy\Argument;
+
+/**
+ * Tests the filesystem decorator to handle chmod().
+ */
+class FileSystemTest extends UnitTestCase {
+
+  /**
+   * Test that we pass public files straight through to chmod().
+   */
+  public function testPublicChmod() {
+    $settings = new Settings([
+      'flysystem' => [
+        's3' => [
+          'driver' => 's3',
+          'config' => [
+            'options' => [
+              'ACL' => 'public-read',
+            ],
+          ],
+        ],
+      ],
+    ]);
+    $decorated = $this->prophesize(FileSystemInterface::class);
+    $decorated->uriScheme(Argument::type('string'))->willReturn('s3');
+    $decorated->chmod('s3://test.txt', NULL)->willReturn(TRUE);
+    $decorated = $decorated->reveal();
+    $filesystem = new FileSystem($settings, $decorated);
+    $this->assertTrue($filesystem->chmod('s3://test.txt'));
+  }
+
+  /**
+   * Test that private files are chmod'ed correctly.
+   */
+  public function testPrivateChmod() {
+    $structure = [
+      'test.txt' => 'test file',
+      'directory' => [],
+    ];
+    $root = vfsStream::setup('s3', NULL, $structure);
+
+    $settings = new Settings([
+      'flysystem' => [
+        'vfs' => [
+          'driver' => 's3',
+          'config' => [
+            'options' => [
+              'ACL' => 'private',
+            ],
+          ],
+        ],
+      ],
+    ]);
+    $decorated = $this->prophesize(FileSystemInterface::class);
+    $decorated->uriScheme(Argument::type('string'))->willReturn('vfs');
+    $decorated->chmod('vfs://s3/test.txt', 0600)->willReturn(TRUE);
+    $decorated->uriScheme(Argument::type('string'))->willReturn('vfs');
+    $decorated->chmod('vfs://s3/directory', 0700)->willReturn(TRUE);
+    $decorated = $decorated->reveal();
+
+    $filesystem = new FileSystem($settings, $decorated);
+    $this->assertTrue($filesystem->chmod(vfsStream::url('s3/test.txt')));
+    $this->assertTrue($filesystem->chmod(vfsStream::url('s3/directory')));
+  }
+
+}
diff --git a/tests/src/Unit/Flysystem/S3Test.php b/tests/src/Unit/Flysystem/S3Test.php
index fd4b67d..47cfa73 100644
--- a/tests/src/Unit/Flysystem/S3Test.php
+++ b/tests/src/Unit/Flysystem/S3Test.php
@@ -2,20 +2,25 @@
 
 namespace NoDrupal\Tests\flysystem_s3\Unit\Flysystem;
 
-use Aws\AwsClientInterface;
 use Aws\Credentials\Credentials;
 use Aws\S3\S3Client;
 use Aws\S3\S3ClientInterface;
 use Drupal\Core\Cache\MemoryBackend;
 use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\Core\PageCache\ResponsePolicy\KillSwitch;
+use Drupal\Core\PageCache\ResponsePolicyInterface;
+use Drupal\Core\Render\RendererInterface;
 use Drupal\Tests\UnitTestCase;
 use Drupal\flysystem_s3\Flysystem\S3;
+use GuzzleHttp\Psr7;
 use League\Flysystem\AdapterInterface;
+use League\Flysystem\AwsS3v3\AwsS3Adapter;
 use League\Flysystem\Config;
 use Prophecy\Argument;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\HttpFoundation\Response;
 
 /**
  * @coversDefaultClass \Drupal\flysystem_s3\Flysystem\S3
@@ -24,6 +29,12 @@ use Symfony\Component\HttpFoundation\RequestStack;
  */
 class S3Test extends UnitTestCase {
 
+  protected $renderer;
+
+  public function setUp() {
+    $this->renderer = $this->prophesize(RendererInterface::class)->reveal();
+  }
+
   public function test() {
     $configuration = [
       'bucket' => 'example-bucket',
@@ -36,8 +47,16 @@ class S3Test extends UnitTestCase {
       'region' => 'beep',
       'credentials' => new Credentials('fsdf', 'sfsdf'),
     ]);
+    $client = $this->prophesize(S3Client::class);
+    $client->getCommand('getObjectAcl', Argument::type('array'))->willReturn($this->prophesize(\Aws\Command::class)->reveal());
+    $client->execute(Argument::type('\Aws\Command'))->willReturn(new \Aws\Result([
+      'Grants' => [
+        ['Grantee' => ['URI' => AwsS3Adapter::PUBLIC_GRANT_URI], 'Permission' => 'READ'],
+      ],
+    ]));
+    $client = $client->reveal();
 
-    $plugin = new S3($client, new Config($configuration));
+    $plugin = new S3($client, new Config($configuration), $this->renderer);
 
     $this->assertInstanceOf(AdapterInterface::class, $plugin->getAdapter());
 
@@ -45,14 +64,39 @@ class S3Test extends UnitTestCase {
 
     $configuration['prefix'] = '';
 
-    $plugin = new S3($client, new Config($configuration));
+    $plugin = new S3($client, new Config($configuration), $this->renderer);
     $this->assertSame('http://example.com/example-bucket/foo%201.html', $plugin->getExternalUrl('s3://foo 1.html'));
   }
 
+  /**
+   * Test merging defaults into configuration arrays.
+   */
+  public function testMergeConfiguration() {
+    $container = new ContainerBuilder();
+    $container->set('request_stack', new RequestStack());
+    $container->get('request_stack')->push(Request::create('https://example.com/'));
+
+    $configuration = [
+      'key'    => 'fee',
+      'secret' => 'fo',
+      'region' => 'eu-west-1',
+      'bucket' => 'example-bucket',
+    ];
+
+    $configuration = S3::mergeConfiguration($container, $configuration);
+    $this->assertEquals('https', $configuration['protocol']);
+
+    $client_config = S3::mergeClientConfiguration($container, $configuration);
+    $this->assertEquals('eu-west-1', $client_config['region']);
+    $this->assertNull($client_config['endpoint']);
+    $this->assertInstanceOf('\Aws\Credentials\Credentials', $client_config['credentials']);
+  }
+
   public function testCreate() {
     $container = new ContainerBuilder();
     $container->set('request_stack', new RequestStack());
     $container->get('request_stack')->push(Request::create('https://example.com/'));
+    $container->set('renderer', $this->renderer);
 
     $configuration = [
       'key'    => 'fee',
@@ -61,14 +105,23 @@ class S3Test extends UnitTestCase {
       'bucket' => 'example-bucket',
     ];
 
-    $plugin = S3::create($container, $configuration, '', '');
-    $this->assertSame('https://s3-eu-west-1.amazonaws.com/example-bucket/foo%201.html', $plugin->getExternalUrl('s3://foo 1.html'));
+    $client = $this->prophesize(S3Client::class);
+    $client->getCommand('getObjectAcl', Argument::type('array'))->willReturn($this->prophesize(\Aws\Command::class)->reveal());
+    $client->execute(Argument::type('\Aws\Command'))->willReturn(new \Aws\Result([
+      'Grants' => [
+        ['Grantee' => ['URI' => AwsS3Adapter::PUBLIC_GRANT_URI], 'Permission' => 'READ'],
+      ],
+    ]));
+    $client = $client->reveal();
+    $plugin = new S3($client, new Config($configuration), $this->renderer);
+    $this->assertInstanceOf('\Drupal\flysystem_s3\Flysystem\S3', $plugin);
   }
 
   public function testCreateUsingNonAwsConfiguration() {
     $container = new ContainerBuilder();
     $container->set('request_stack', new RequestStack());
     $container->get('request_stack')->push(Request::create('https://example.com/'));
+    $container->set('renderer', $this->renderer);
 
     $configuration = [
       'key'      => 'fee',
@@ -79,7 +132,6 @@ class S3Test extends UnitTestCase {
     ];
 
     $plugin = S3::create($container, $configuration, '', '');
-    $this->assertSame('https://something.somewhere.tld/foo%201.html', $plugin->getExternalUrl('s3://foo 1.html'));
     $this->assertSame('https://api.somewhere.tld', (string) $plugin->getAdapter()->getClient()->getEndpoint());
   }
 
@@ -87,6 +139,7 @@ class S3Test extends UnitTestCase {
     $container = new ContainerBuilder();
     $container->set('request_stack', new RequestStack());
     $container->get('request_stack')->push(Request::create('http://example.com/'));
+    $container->set('renderer', $this->renderer);
 
     $configuration = [
       'key'      => 'foo',
@@ -97,7 +150,6 @@ class S3Test extends UnitTestCase {
     ];
 
     $plugin = S3::create($container, $configuration, '', '');
-    $this->assertSame('http://storage.example.com/my-bucket/foo%201.html', $plugin->getExternalUrl('s3://foo 1.html'));
     $this->assertSame('https://api.somewhere.tld', (string) $plugin->getAdapter()->getClient()->getEndpoint());
   }
 
@@ -107,19 +159,53 @@ class S3Test extends UnitTestCase {
       'bucket'   => 'my-bucket',
     ];
 
-    $plugin = new S3($this->getMock(S3ClientInterface::class), new Config($configuration));
+    $client = $this->prophesize(S3Client::class);
+    $client->getCommand('getObjectAcl', Argument::type('array'))->willReturn($this->prophesize(\Aws\Command::class)->reveal());
+    $client->execute(Argument::type('\Aws\Command'))->willReturn(new \Aws\Result([
+      'Grants' => [
+        ['Grantee' => ['URI' => AwsS3Adapter::PUBLIC_GRANT_URI], 'Permission' => 'READ'],
+      ],
+    ]));
+    $client = $client->reveal();
+    $plugin = new S3($client, new Config($configuration), $this->renderer);
     $this->assertSame('http://s3.amazonaws.com/my-bucket/foo.html', $plugin->getExternalUrl('s3://foo.html'));
   }
 
+  /**
+   * Test presigned URL generation.
+   */
+  public function testPresignedUrl() {
+    $configuration = [
+      'bucket'   => 'my-bucket',
+      'expires' => '+10 seconds',
+    ];
+
+    $client = $this->prophesize(S3Client::class);
+    $client->getCommand('getObjectAcl', Argument::type('array'))->willReturn($this->prophesize(\Aws\Command::class)->reveal());
+    $client->execute(Argument::type('\Aws\Command'))->willReturn(new \Aws\Result([
+      'Grants' => [
+        [],
+      ],
+    ]));
+    $client->getCommand('getObject', Argument::type('array'))->willReturn($this->prophesize(\Aws\Command::class)->reveal());
+    $request = new Psr7\Request('GET', 'https://s3.amazonaws.com/signed');
+    $client->createPresignedRequest(Argument::type('\Aws\Command'), '+10 seconds')->willReturn($request);
+    $client = $client->reveal();
+    $kill_switch = new KillSwitch();
+    $plugin = new S3($client, new Config($configuration), $this->renderer, $kill_switch);
+    $this->assertSame('https://s3.amazonaws.com/signed', $plugin->getExternalUrl('s3://foo.html'));
+    $this->assertEquals(ResponsePolicyInterface::DENY, $kill_switch->check(new Response(), new Request()));
+  }
+
   public function testEnsure() {
     $client = $this->prophesize(S3ClientInterface::class);
     $client->doesBucketExist(Argument::type('string'))->willReturn(TRUE);
-    $plugin = new S3($client->reveal(), new Config(['bucket' => 'example-bucket']));
+    $plugin = new S3($client->reveal(), new Config(['bucket' => 'example-bucket']), $this->renderer);
 
     $this->assertSame([], $plugin->ensure());
 
     $client->doesBucketExist(Argument::type('string'))->willReturn(FALSE);
-    $plugin = new S3($client->reveal(), new Config(['bucket' => 'example-bucket']));
+    $plugin = new S3($client->reveal(), new Config(['bucket' => 'example-bucket']), $this->renderer);
 
     $result = $plugin->ensure();
     $this->assertSame(1, count($result));
@@ -130,6 +216,7 @@ class S3Test extends UnitTestCase {
     $container = new ContainerBuilder();
     $container->set('request_stack', new RequestStack());
     $container->get('request_stack')->push(Request::create('https://example.com/'));
+    $container->set('renderer', $this->renderer);
     $container->set('cache.default', new MemoryBackend('bin'));
 
     $configuration = ['bucket' => 'example-bucket'];
