UrlSignerBundle, create and validate signed URLs with Symfony

A signed URL is a URL containing authentication information directly in its query string. It allows a non-authenticated user to access protected resources or routes, for a limited time.

Signing a URL can be very useful in a stateless application. There are two main use cases for using them:

  • allowing an external user to access a resource otherwise not accessible without being authenticated,
  • downloading a file with the browser without using Ajax (without a fetch() sending the authentication information).

The latter is the most common use case. Let's take an example: we have created a personal cloud where users can store their files. This application is built with a SPA for the front and communicates with a secured API by using a JWT token. Jean has uploaded a big video showing a live coding session of a nice feature in API Platform, and he wants to download it to watch it again.

The first reflex to implement this download feature in the SPA would be to do something like this:

const res = await fetch('videos/d35e0296-7b19-42be-82f2-789213229f36/download', {
  headers: {
    Authorization: `Bearer ${localStorage.getItem('token')}`,
    Accept: 'application/octet-stream',
  },
});
const blob = await res.blob();
const objUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objUrl;
link.download = true;
link.click();

This method has a lot of drawbacks:

  • The browser download interface is used only after the file has been downloaded. We need to show Jean that a download is ongoing, for instance by adding a spinner somewhere. Jean cannot pause or cancel the download.
  • The downloaded file is stored in memory until it is complete, which can cause performance issues if the file is too big.

To solve this, you could use the JWT token in the query string to download the video. Yes, it is possible, but it is a bad idea:

  • A JWT token can be big. Big enough to exceed the URL length limit.
  • You don't want the JWT tokens to show in your server logs. This is a security breach in your application that would facilitate a session hijacking kind of attack.

That's why in this case, it is preferable to use a signed URL, like this:

const res = await fetch('videos/d35e0296-7b19-42be-82f2-789213229f36/signed-url', {
  headers: {
    Authorization: `Bearer ${localStorage.getItem('token')}`,
  },
});
const { url } = await res.json();
const link = document.createElement('a');
link.href = url;
link.download = true;
link.click();

That's way better: fetch() is only used to get the signed URL with authentication, signed URL which is then used directly to download the file. And of course you can also use this signed URL generation for adding a sharing feature, for instance if Jean wants his friends to download the video without being authenticated.

Laravel includes the generation of signed URLs since its version 5.6, but there was no easy way to do it in Symfony.

With the UrlSignerBundle, it has now been fixed!

So, how do we use it?

Usage

First step, install the bundle. By using Symfony Flex, it's very straightforward:

composer req tilleuls/url-signer-bundle

Thanks to Flex, all the configuration has been done (see the config/packages/url_signer.yaml file).

Change the signature in the .env (or the .env.local) file:

SIGNATURE_KEY=XAR5vxusewjY9KJ2w8apxVzVq5EPtl

Everything is ready to start using the bundle and to create your signed URL!

For instance in a controller, we will inject the UrlSignerInterface service and then use it to create a signed URL which will be returned in the JSON response of the /videos/{id}/signed-url route:

<?php

declare(strict_types=1);

namespace App\Controller;

use CoopTilleuls\UrlSignerBundle\UrlSigner\UrlSignerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class VideoController extends AbstractController
{
    public function __construct(
        private UrlSignerInterface $urlSigner,
    ) {}

    #[Route('/videos/{id}/signed-url', methods: ['GET'])]
    public function newSignedUrl(string $id): Response
    {
        $this->denyAccessUnlessGranted('ROLE_USER');

        return new JsonResponse(['url' => $this->generateSignedUrl($id)]);
    }

    private function generateSignedUrl(string $id): string
    {
        $url = $this->generateUrl('download_video', ['id' => $id]);
        // Will expire after 10 seconds.
        $expiration = (new \DateTime('now'))->add(new \DateInterval('PT10S'));

        return $this->urlSigner->sign($url, $expiration);
    }
}

We can now retrieve a signed URL from the /videos/{id}/signed-url route. The signed URL will look like this:

/videos/d35e0296-7b19-42be-82f2-789213229f36/download?expires=1611316656&signature=82f6958bd5c96fda58b7a55ade7f651fadb51e12171d58ed271e744bcc7c85c3

As you can see, the signed URL has two parameters:

  • expires which contains the timestamp of the expiration date: the URL is no longer valid after this date.
  • signature which is in fact the hash of the path, the expiration parameter and the secret.

The last step is to validate it when the route corresponding to the URL is accessed. We reuse our VideoController:

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class VideoController extends AbstractController
{
    #[Route('/videos/{id}/download', name: 'download_video', defaults: ['_signed' => true], methods: ['GET'])]
    public function download(string $id): Response
    {
        // Get the file.

        return new BinaryFileResponse();
    }
}

As you can see, we just added a _signed extra parameter to the route. It is this parameter that activates the signature validation when a request hits the route.

And that's it! If you access the download route without a signature or with an invalid signature, you will get a 403 response.

If you want to use signed URLs in API Platform, how do you do it? Well it's even easier!

It will look like this:

<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use App\Controller\CreateSignedUrlVideoAction;
use App\Controller\DownloadVideoAction;

#[ApiResource(
    itemOperations: [
        'get',
        'delete',
        'put',
        'signed_url' => [
            'security' => "is_granted('ROLE_USER')",
            'method' => 'GET',
            'path' => '/videos/{id}/signed-url',
            'controller' => CreateSignedUrlVideoAction::class,
        ],
        'download' => [
            'method' => 'GET',
            'path' => '/videos/{id}/download',
            'controller' => DownloadVideoAction::class,
            'defaults' => ['_signed' => true],
            'formats' => ['binary'],
        ],
    ],
)]
class Video
{
    // Properties, getters, setters
}

The signed_url operation creates the signed URL (and has authentication). The download operation allows users to download the video using the signed URL, with the extra _signed parameter that enables validation of the signed URL. The corresponding controllers are very similar to the previous VideoController controller.

Features

There are of course other features in the bundle:

  • The algorithm to sign the URL can be changed. By default, it is SHA-256.
  • You can easily create your own Signer: extend the AbstractUrlSigner class, and you are done (you can use it by modifying the configuration in the config/packages/url_signer.yaml file)!
  • The time before expiration is customizable (by default 24h).
  • Parameter names in the signed URL are customizable.

How does it work?

Internally the bundle uses the library spatie/url-signer.

Each Signer corresponds to an algorithm and extends an abstract class (AbstractUrlSigner) based on the Spatie URL signer. In a dedicated compiler pass, the configured signer is automatically aliased to the UrlSignerInterface.

For the validation, the method is really simple: an event listener is used. Each time a request is performed, it checks if the route has the _signed parameter. When it does, the signature is validated by the URL signer.

The bundle is very well tested: it has unit tests with PHPUnit and Prophecy and functional tests with Behat and the SymfonyExtension. The tests cover 100% of the code, and even better, the mutation score, calculated with the mutation testing framework Infection is also 100%.

And finally, the type coverage is 100% too, calculated by Psalm (PHPStan is also used).

If you need signed URLs in a Symfony or API Platform project, this bundle is for you! Feel free to test it, tell us what you think about it, and why not contribute to its development on GitHub: https://github.com/coopTilleuls/UrlSignerBundle.