Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

ericbarber's avatar

Signed Routes and Expiration

Hello,

I am building a video tutorial site with a paywall. For obvious reasons I do not want the videos in a publicly accessible directory. To overcome this I moved my videos folder to the storage directory and implemented a file controller to serve the file up when requested.

This works great! I saw that in the (I think) 5.6.12 release URL signing was included in Laravel and I tried it out, it works great as well.

I would like to deter the "normal" internet users from downloading the videos I am hosting as they are watching them, so I set up URL signing on the file controller route with a quick expiration (30s). I had a couple questions about this:

  1. The video (even though it is about four minutes) continues to download past the link expiration. Is this a safe practice since the file controller is handling the process after opening the link?

  2. What would be the best way to implement link expiration based on usage and not on time? It would be perfect if I could say that instead of "30s from now" the link is garbage that "one use from now" the link is garbage.

  3. Am I going about this correctly? I know that once someone has your content in front of them it should be assumed that they kept it. I (and my client) just want a first line of defense to discourage the average user from sharing the videos.

Thanks!

0 likes
3 replies
GM's avatar

Hi Eric, this looks like exactly the sort of thing I'm trying to resolve as well. I need to generate an array of temporary URI's with a lifetime and token. How did you configure your route and file controller? I am hoping to pass to the Javascript the URI of the resource complete with timeout value and signing token with an hour or so link duration lifetime, then have the JS load the video via ThreeJs loading.

ericbarber's avatar

Hey GM,

My file controller:

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class FileController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth');
    }
  
    public function getFile($filename)
    {
        return response()->download(storage_path('video/' . $filename), null, [], null);
    }

    public function downloadPDF($filename)
    {
        return response()->download(storage_path('pdf/' . $filename), null, []);
    }
}

My route:

Route::get('file/{filename}', 'FileController@getFile')->where('filename', '^[^/]+$')->name('video')->middleware('signed');

Calling the route from the controller:

$lesson->video_url = URL::temporarySignedRoute('video', now()->addMinutes(10), ['video_title' => $lesson->video_title]);

It's not exactly what I was looking for (single use) but it certainly discourages sharing of the links since they're only good for 10 minutes.

Let me know if there's any additional information I can get to you.

Eric

GM's avatar

Hi Eric,

Yes that looks pretty much like where I ended up yesterday. This morning I went back a step and thought

Why aren't I doing this on NGINX?

The down side of handling this in Laravel is that you have to boot your application every time you access a video. The response will take longer compared to the response direct from the NGINX web server which would serve the video normally for you if it was under the public folder. We would have to make this trade off of security vs performance.

Instead, we can use the secure link module of NGINX. To get this you either need to compile the source or apt get install nginx-extras

The Secure Link module verifies the validity of a requested resource by comparing an encoded string in the URL of the HTTP request with the string it computes for that request. If a link has a limited lifetime and the time has expired, the link is considered outdated. The status of these checks is captured in the $secure_link variable and used to control the flow of processing.

There are two secure_link methods available. The preferred method in this case is enabled by the secure_link and secure_link_md5 directives. Here the encoded string is an MD5 hash of variables defined in the NGINX configuration file. Most commonly, the $remote_addr variable is included to restrict access to a particular client IP address, but you can use other values, for example $http_user_agent, which captures the User-Agent header and so restricts access to certain browsers.

Optionally, you can specify an expiration date after which the URL no longer works even if the hash is correct.

The client must append the md5 argument to the request URL to specify the hash. If an expiration date is included in the string that is hashed, the client also must append the expires argument to specify the date.

Within the NGINX default configuration file, we have included a section

        location /secure/ {
                secure_link $arg_md5,$arg_expires;
                secure_link_md5 "$secure_link_expires$uri$remote_addr MySupirS3cretPass";

                if ($secure_link = "") { return 403; }
                if ($secure_link = "0") { return 410; }

        }

In this instance our server listens to incoming requests and handles all secured HTTP(/S) requests under the location /secure/ block.

The secure_link directive defines two variables that capture arguments in the request URL: $arg_md5 is set to the value of the md5 argument, and $arg_expires to the value of the expires argument.

The secure_link_md5 directive defines the expression that is hashed to generate the MD5 value for the request; during URL processing, the hash is compared to the value of $arg_md5. The sample expression here includes the expiration time passed in the request (captured in the $secure_link_expires variable), the URL ($uri), the client IP address ($remote_addr), and the secret word "MySupirS3cretPass"

If the hash in the URL sent by the client (captured in the $arg_md5 variable) does not match the hash calculated from the secure_link_md5 directive, NGINX sets the $secure_link variable to the empty string. The if test fails and NGINX returns the 403 Forbidden status code in the HTTP response.

If the hashes match but the link has expired, NGINX Plus sets the $secure_link variable to 0; again the if test fails but this time NGINX returns the 410 Gone status code in the HTTP response.

Generating the hash and expiration time is easy. Rather than writing a laravel package or class, I just created a helper.

private function buildSecureLink($baseUrl, $path, $secret, $userIp)
{
    $expires = Carbon::now()-addHour()->toTimestamp();
    $md5 = md5("$expires$path$userIp $secret", true);
    $md5 = base64_encode($md5);
    $md5 = strtr($md5, '+/', '-_');
    $md5 = str_replace('=', '', $md5);
    return $baseUrl . $path . '?md5=' . $md5 . '&expires=' . $expires;
}
// example usage
$secret = 'MySupirS3cretPass';
$baseUrl = 'https://www.videos.com';
$path = '/secure/2018/thru-2018-02-14/20180214-013000.mp4';
$userIp = $_SERVER['REMOTE_ADDR'];

$secureLink = buildSecureLink($baseUrl, $path, $secret, $userIp);

Alternatively you may want to create a time from now in seconds.

private function buildSecureLink($baseUrl, $path, $secret, $ttl, $userIp)
{
    $expires = time() + $ttl;
    $md5 = md5("$expires$path$userIp $secret", true);
    $md5 = base64_encode($md5);
    $md5 = strtr($md5, '+/', '-_');
    $md5 = str_replace('=', '', $md5);
    return $baseUrl . $path . '?md5=' . $md5 . '&expires=' . $expires;
}
// example usage
$secret = 'MySupirS3cretPass';
$baseUrl = 'https://www.videos.com';
$path = '/secure/2018/thru-2018-02-14/20180214-013000.mp4';
$ttl = 240;     //no of seconds this link is active
$userIp = $_SERVER['REMOTE_ADDR']; 

$secureLink = buildSecureLink($baseUrl, $path, $secret, $ttl $userIp);

This approach makes a lot more sense to me as I can use a proxy_pass in the same block if I need to fetch the files from a back-end webserver and I dont have to boot Laravel to serve the video files.

Greg

Please or to participate in this conversation.