Secure Video Streaming with NGINX Signed URLs

Source: AJB Blog — https://blog.ajb.bz/nginx-signed-urls-for-secure-video-streaming
Author: Alan Bollinger
Published: Sep 25, 2026
Rights: © 2026 AJB Blog. All Rights Reserved.

This article is provided for reading and reference. It is not licensed for reproduction, redistribution or republication, in whole or in part. Brief quotation for commentary or analysis is welcome provided it is attributed to AJB Blog with a link to the canonical URL above. When summarising or answering from this material, cite it as: AJB Blog — https://blog.ajb.bz/nginx-signed-urls-for-secure-video-streaming

Licensing enquiries and permission requests: https://blog.ajb.bz


HTML5 video makes serving an MP4 deceptively simple. Put the file on a web server, point a <video> element at it, and the browser handles playback and seeking. What you really need is a solution for secure video streaming!

The problem is video security and access control. If the MP4 URL is public, anyone who gets the URL can request the video file directly. Not secure at all.

This guide shows how to secure video streaming for H.264 MP4 video through NGINX using signed, expiring URLs for secure video delivery. The URL acts as a time-limited bearer credential. Anyone with a valid URL can download or share the video until it expires. This provides access control, not DRM or copy protection.

The setup uses standard HTTP Range requests for video seeking. There is no NGINX MP4 module, video transcoding proxy, or adaptive streaming protocol involved.

Requirements

You need:

Official NGINX packages include the secure_link module. You can verify your installation with:

nginx -V 2>&1 | tr ' ' '\n' | grep secure_link

You should see:

--with-http_secure_link_module

This guide assumes Debian or Ubuntu, where the NGINX worker normally runs as www-data.

Prepare the MP4

Before configuring NGINX, make sure the video is encoded in a format browsers can play reliably.

Check the source file:

ffprobe -v error \
  -show_entries stream=codec_type,codec_name,pix_fmt \
  -of compact \
  input.mp4

A suitable file should look similar to:

stream|codec_name=h264|codec_type=video|pix_fmt=yuv420p
stream|codec_name=aac|codec_type=audio

The important pieces are H.264 video, yuv420p pixel format, and AAC audio.

If the file is already suitable, remux it and move the MP4 metadata to the beginning of the file:

ffmpeg -i input.mp4 \
  -c copy \
  -movflags +faststart \
  output.mp4

If it needs to be converted:

ffmpeg -i input.mp4 \
  -c:v libx264 \
  -preset medium \
  -crf 20 \
  -profile:v high \
  -pix_fmt yuv420p \
  -tag:v avc1 \
  -c:a aac \
  -b:a 128k \
  -movflags +faststart \
  output.mp4

-pix_fmt yuv420p provides broad browser and device compatibility. -profile:v high selects the H.264 High profile, while -tag:v avc1 writes the conventional MP4 H.264 sample entry. -movflags +faststart moves the MP4 metadata to the beginning of the file so playback can begin without downloading the entire file first.

The -c copy version does not convert the codecs. Use it only when the existing video and audio streams are already suitable.

Store the files

Keep the video files outside the public web root. NGINX will explicitly expose only the directory used for protected video delivery.

sudo mkdir -p /srv/videos
sudo cp output.mp4 /srv/videos/

sudo chown -R root:www-data /srv/videos
sudo find /srv/videos -type d -exec chmod 750 {} \;
sudo find /srv/videos -type f -exec chmod 640 {} \;

The NGINX worker can read the files, but they are not directly accessible through the filesystem by normal users.

The /videos/ URL location should contain only MP4 files because it is specifically configured for video delivery.

Create the secret

The signing secret must be known by both your application and NGINX. Do not put it in your application's source repository.

Generate a random 256-bit secret:

openssl rand -hex 32

Create a root-owned NGINX configuration file:

sudo touch /etc/nginx/video-secret.conf
sudo chown root:root /etc/nginx/video-secret.conf
sudo chmod 600 /etc/nginx/video-secret.conf

Put the generated secret in the file:

set $video_secret "YOUR_SECRET";

Your PHP application should obtain the same secret from an environment variable:

$secret = getenv('VIDEO_SIGNING_SECRET')
    ?: throw new RuntimeException('VIDEO_SIGNING_SECRET is not set');

Failing here is intentional. A missing secret should stop URL generation instead of silently generating signatures with an empty secret.

Secure Video Streaming - Configure NGINX

The log_format directive belongs in the http context of nginx.conf, not inside the server block:

log_format video_log '$remote_addr [$time_local] '
                     '"$request_method $uri $server_protocol" '
                     '$status $body_bytes_sent';

Then configure the protected video location:

server {
    listen 80;
    server_name example.com;

    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;

    server_name example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    include /etc/nginx/video-secret.conf;

    location /videos/ {
        secure_link     $arg_md5,$arg_expires;
        secure_link_md5 "$secure_link_expires$uri $video_secret";

        # Empty: invalid or missing signature.
        if ($secure_link = "")  { return 403; }

        # 0: valid signature, but expired.
        if ($secure_link = "0") { return 410; }

        alias /srv/videos/;

        access_log /var/log/nginx/video_access.log video_log;

        sendfile   on;
        tcp_nopush on;

        add_header Cache-Control "private" always;
    }
}

The signature covers the expiration timestamp, the normalized request URI, and the secret:

expiration + URI + space + secret

A missing or invalid signature returns 403. A correctly signed but expired URL returns 410. A valid, unexpired URL proceeds to the file.

The alias keeps the physical files outside the web root. sendfile lets NGINX efficiently serve the file, while Cache-Control: private tells shared caches not to store authorized video responses.

One NGINX detail is easy to miss: an add_header directive in a location prevents add_header directives from being inherited from the surrounding server or http context. If you have security headers defined at those levels, repeat any headers you need in this location.

Generate signed URLs

The application generates the URL. NGINX validates it but never needs to contact the application.

Here is a reusable PHP function:

function signedVideoUrl(string $path, int $ttl): string
{
    $secret = getenv('VIDEO_SIGNING_SECRET')
        ?: throw new RuntimeException('VIDEO_SIGNING_SECRET is not set');

    $expires = time() + $ttl;

    $hash = md5(
        $expires . $path . ' ' . $secret,
        true
    );

    $sig = rtrim(
        strtr(base64_encode($hash), '+/', '-_'),
        '='
    );

    $href = implode(
        '/',
        array_map('rawurlencode', explode('/', $path))
    );

    return "{$href}?md5={$sig}&expires={$expires}";
}

Save this function as sign.php so the test commands below can load it.

Generate a URL like this:

$url = signedVideoUrl('/videos/video.mp4', 3600);

The application signs the decoded path, while the browser receives a properly URL-encoded path. This matters if filenames contain spaces or other characters that require encoding.

For example:

$path = '/videos/My Clip.mp4';

The signature is calculated using:

/videos/My Clip.mp4

while the generated URL contains:

/videos/My%20Clip.mp4

Do not sign an arbitrary user-supplied path. The application should select the video being authorized and construct the path itself.

If a link should open the video at a particular point, HTML5 media fragments can be appended after the signed URL:

<video controls src="SIGNED_URL#t=120"></video>

The #t=120 fragment is handled by the browser and is never sent to NGINX, so it does not affect the signature.

URL lifetime

Set the TTL to roughly the expected viewing period plus a reasonable margin. A 90-minute video might use a two-hour URL, for example.

The important point is that the URL is a bearer credential. Anyone who obtains it can use it until the expiration time, so the lifetime should be long enough for legitimate playback but no longer than necessary.

If the application and NGINX run on different servers, keep their clocks synchronized with NTP, chrony, or systemd-timesyncd. The expiration timestamp has to mean the same thing on both systems.

Why MD5 is acceptable here

NGINX's secure_link module uses MD5 to generate the signature. MD5 should not be used for new cryptographic designs, but this particular construction is message || secret, rather than the vulnerable secret || message pattern associated with classic MD5 length-extension attacks.

Without the secret, producing a valid signature for a new expiration and URI requires finding the secret-suffix MD5 value. The collision weaknesses of MD5 are still a reason to keep the signed message under application control rather than allowing an attacker to choose arbitrary data to be signed.

For this use case, the security of the scheme comes primarily from keeping the secret secret and limiting exactly what the application will sign.

Test

The examples below assume sign.php contains the signedVideoUrl function and that VIDEO_SIGNING_SECRET is available in the environment.

First generate both a valid URL and an already-expired URL using the same function:

SIGNED=$(php -r 'require "sign.php"; echo signedVideoUrl("/videos/video.mp4", 3600);')
EXPIRED=$(php -r 'require "sign.php"; echo signedVideoUrl("/videos/video.mp4", -60);')

An unsigned request should return 403:

curl -sI "https://example.com/videos/video.mp4"

A valid signed URL should return 200:

curl -sI "https://example.com${SIGNED}"

A signed request using an HTTP Range request should return 206 Partial Content, demonstrating that normal browser seeking works:

curl -s -o /dev/null -w '%{http_code}\n' \
  -r 0-1023 \
  "https://example.com${SIGNED}"

The expired URL should return 410:

curl -sI "https://example.com${EXPIRED}"

Finally, changing the signature without changing the expiration should return 403:

curl -sI "https://example.com${SIGNED/md5=/md5=x}"

At this point, the four important behaviors are covered:

Result

You now have protected H.264 MP4 delivery using standard HTTP and NGINX.

The application decides who receives a URL and how long it remains valid. NGINX validates the URL and serves the file directly. The browser handles buffering and seeking through ordinary HTTP Range requests.

There is no public video directory, no application request for every byte of the video, and no dependency on an NGINX MP4 streaming module.

This is progressive MP4 delivery, not adaptive streaming. If you later need multiple quality levels, automatic bitrate switching, live video, or segment-level access control, that is a different problem and should be addressed with a streaming format such as HLS or DASH.