How to Implement Tokenized HLS Streaming Using the Nginx Secure Link Module

Stop video hotlinking and bandwidth theft at the edge without a single third-party dependency.

Video hotlinking is a silent budget killer. Every unauthorized embed pulls bandwidth directly from your origin server, bypasses your paywall, and degrades the experience for paying users. The Nginx Secure Link module solves this natively no middleware, no token server, no extra latency.

How does the Nginx Secure Link Module work?

The Nginx Secure Link module validates incoming video requests by computing an MD5 hash from a secret key, the request URI, the client IP, and an expiration timestamp all inside Nginx itself. Requests with a missing, mismatched, or expired hash are rejected with a 403 or 410 before a single byte of video is read from disk.

What You'll Learn

Key Takeaways

  • The Nginx Secure Link module validates tokens natively at the edge zero application-layer overhead per video request.

  • Token security relies on a server-side secret combined with the client IP + expiry timestamp + URI, making tokens non-transferable.

  • Securing only the .m3u8 manifest is not enough .ts segments and AES key endpoints must be individually protected.

  • MD5 hashing at the edge is significantly faster and less resource-intensive than parsing JSON Web Tokens (JWT) for high-throughput video delivery.

What You Will Build

  • OS: AlmaLinux 9 or Ubuntu 22.04 LTS

  • Web server: Nginx (compiled with --with-http_secure_link_module)

  • Backend token generator: PHP 8+ and Python 3.10+

  • Video format: HTTP Live Streaming (HLS) .m3u8 manifests + .ts segments + AES-128 key files

  • Optional AES encryption: Protected decryption key endpoint secured by the same token pipeline

The Architecture: How Tokenized HLS Works

Before writing a single config line, understand the full security handshake. There are four steps.

The Four-Step Token Flow

Step 1 — Client requests access from your backend: The video player (or browser) calls your application (PHP, Python, Node.js) to request a playback URL. It does not contact Nginx directly yet.

Step 2 — Backend generates a time-limited MD5 token: Your application assembles a hash string using:

  • A secret word stored only on the server
  • The client IP address $remote_addr
  • A Unix expiration timestamp (e.g., now + 3600 seconds)
  • The URI path of the video resource

This produces a Base64url-encoded MD5 digest. The backend returns a complete tokenized URL to the player.

Step 3 — Player requests the tokenized URL from Nginx: The player sends a request to Nginx that includes md5 and expires as query parameters.

Step 4 — Nginx intercepts, recalculates, and decides: Nginx recomputes the expected MD5 hash using the same formula. If the hashes match and the timestamp has not expired, Nginx serves the file (200 OK). If they diverge, it returns 403 Forbidden. If the token is valid but expired, 410 Gone. The entire validation happens inside Nginx's worker process — no upstream call, no database lookup, no I/O.

Why This Beats JWTs for Video Streaming

JSON Web Tokens are excellent for API authentication. For video segment delivery, they are overkill.

Factor MD5 Secure Link JWT
Validation location Nginx worker (C, edge) Application layer (PHP/Python/Node)
Per-request overhead Nanoseconds (hash comparison) Milliseconds (JSON parse + signature verify)
Dependencies None (built into Nginx) JWT library, middleware
HLS segment volume Handles thousands/sec natively Creates upstream bottleneck
IP binding Built-in ($remote_addr) Manual claim required

For a 10-second HLS stream at 2-second segment boundaries, a player makes 5 requests per 10 seconds per viewer. At 1,000 concurrent viewers, that is 500 validation requests per second. MD5 at the edge absorbs this. An application-layer JWT validator will not.

Prerequisites

Before starting, confirm the following:

  • A dedicated server or VPS with root SSH access. For production HLS delivery, a bare-metal dedicated server gives you the consistent I/O throughput and network headroom that shared VPS plans cannot guarantee. BytesRack offers dedicated servers purpose-built for media origin workloads worth evaluating before you go live at scale.

  • Nginx installed from source or a package that includes the Secure Link module (verified in Step 1).

  • Your HLS files already generated (using FFmpeg or a transcoding pipeline) and placed in a web-accessible directory.

  • PHP 8+ or Python 3.10+ available on the same server for backend token generation.

Step 1: Verify and Configure Nginx

Check That the Module Is Compiled In

bash
nginx -V 2>&1 | grep -o with-http_secure_link_module

Expected output: with-http_secure_link_module

If nothing returns, the module is absent. On Ubuntu/Debian systems, install the full Nginx build:

bash
sudo apt install nginx-extras

On AlmaLinux/RHEL, use the mainline repo:

bash
sudo dnf install nginx
nginx -V 2>&1 | grep secure_link

If you are compiling from source, add the flag at configure time:

bash
./configure --with-http_secure_link_module [your other flags]
make && sudo make install

The Complete Nginx Server Configuration Block

Create or edit your server block. This configuration covers the HLS manifest and the segment directory:

nginx
server {
    listen 443 ssl;
    server_name video.example.com;

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

    # Root for all HLS files
    root /var/www/hls;

    # ─── Secure Link Configuration ───────────────────────────────────────────
    # Format: md5(expires + URI + client_IP + secret)
    secure_link $arg_md5,$arg_expires;
    secure_link_md5 "$secure_link_expires$uri$remote_addr YourSuperSecretWord2026";

    # ─── Protect the HLS manifest (.m3u8) ────────────────────────────────────
    location ~* \.m3u8$ {
        if ($secure_link = "") { return 403; }   # Missing or invalid token
        if ($secure_link = "0") { return 410; }  # Token expired

        add_header Cache-Control "no-store, no-cache";
        add_header Access-Control-Allow-Origin "*";
        try_files $uri =404;
    }

    # ─── Protect individual TS segments ──────────────────────────────────────
    location ~* \.ts$ {
        if ($secure_link = "") { return 403; }
        if ($secure_link = "0") { return 410; }

        add_header Cache-Control "no-store";
        try_files $uri =404;
    }

    # ─── Protect the AES-128 decryption key ──────────────────────────────────
    location ~* \.key$ {
        if ($secure_link = "") { return 403; }
        if ($secure_link = "0") { return 410; }

        add_header Cache-Control "no-store, private";
        try_files $uri =404;
    }
}

Configuration variable reference:

Variable Meaning
$secure_link Result of Nginx's internal hash comparison. Empty string = invalid/missing. "0" = expired. "1" = valid.
$secure_link_expires The Unix expiration timestamp extracted from the expires query parameter.
$secure_link_md5 The expected MD5 hash string that Nginx computes internally for comparison.
$arg_md5 The MD5 token value passed in the request query string.
$arg_expires The expiration timestamp passed in the request query string.

After editing, always test the config before reloading:

bash
sudo nginx -t && sudo systemctl reload nginx

Step 2: Generating the Secure Token (Backend Code)

The Hash Formula

The MD5 input string is assembled in this exact order spacing matters:

md5( EXPIRES_TIMESTAMP + URI_PATH + CLIENT_IP + " " + SECRET_WORD )

Then the raw MD5 binary is Base64-encoded and made URL-safe (Replace + with -, replace / with _, strip trailing = padding). The final URL format looks like: https://video.example.com/streams/movie/index.m3u8?md5=TOKEN&expires=TIMESTAMP

PHP Token Generator

php
<?php
/**
 * Generate a time-limited Nginx Secure Link token.
 *
 * @param string $uri       Absolute URI path, e.g. /streams/movie/index.m3u8
 * @param string $clientIp  Viewer's IP address ($_SERVER['REMOTE_ADDR'])
 * @param string $secret    Your server-side secret word
 * @param int    $ttl       Token lifetime in seconds (default: 3600)
 * @return string           Full tokenized URL
 */
function generateSecureLink(
    string $uri,
    string $clientIp,
    string $secret,
    int $ttl = 3600
): string {
    $expires   = time() + $ttl;
    $hashInput = "{$expires}{$uri}{$clientIp} {$secret}";

    // Compute raw MD5 binary → Base64 → URL-safe
    $md5 = base64_encode(md5($hashInput, true));
    $md5 = strtr($md5, '+/', '-_');
    $md5 = rtrim($md5, '=');

    $baseUrl = "https://video.example.com{$uri}";
    return "{$baseUrl}?md5={$md5}&expires={$expires}";
}

// Usage
$clientIp  = $_SERVER['REMOTE_ADDR'];
$secret    = 'YourSuperSecretWord2026';
$uri       = '/streams/movie/index.m3u8';

$tokenizedUrl = generateSecureLink($uri, $clientIp, $secret);
echo $tokenizedUrl;
// https://video.example.com/streams/movie/index.m3u8?md5=abc123...&expires=1718000000

Python Token Generator

python
import hashlib
import base64
import time
from urllib.parse import urlencode

def generate_secure_link(
    uri: str,
    client_ip: str,
    secret: str,
    ttl: int = 3600,
    base_url: str = "https://video.example.com"
) -> str:
    """
    Generate a time-limited Nginx Secure Link token.
    """
    expires = int(time.time()) + ttl

    # Exact format Nginx uses: expires + uri + ip + space + secret
    hash_input = f"{expires}{uri}{client_ip} {secret}"

    # MD5 raw binary → Base64 → URL-safe encoding
    raw_md5   = hashlib.md5(hash_input.encode()).digest()
    token     = base64.b64encode(raw_md5).decode()
    token     = token.replace('+', '-').replace('/', '_').rstrip('=')

    params = urlencode({"md5": token, "expires": expires})
    return f"{base_url}{uri}?{params}"


# Usage
if __name__ == "__main__":
    client_ip = "203.0.113.42"   # From request context
    secret    = "YourSuperSecretWord2026"
    uri       = "/streams/movie/index.m3u8"

    url = generate_secure_link(uri, client_ip, secret)
    print(url)
    # https://video.example.com/streams/movie/index.m3u8?md5=abc123...&expires=1718000000

Security note: Store $secret / secret in an environment variable or secrets manager never hardcode it in version-controlled source files. Rotate it periodically.

Step 3: Securing HLS Manifests, TS Segments, and AES Decryption Keys

This is where most tutorials stop and where most implementations fail.

The Big Vulnerability

An HLS stream is not a single file. When a player loads index.m3u8, it parses the manifest and independently requests each .ts segment and the optional AES-128 .key file listed inside it.

If you only token-gate the .m3u8 manifest, an attacker only needs to download the manifest once, parse the segment URLs, and then fetch every .ts file and the decryption key directly because those sub-resources are unprotected.

Real attack sequence:

  • Attacker legitimately requests one tokenized .m3u8 URL.
  • Downloads and parses the manifest.
  • Extracts all .ts segment paths and the #EXT-X-KEY URI.
  • Fetches every segment and the AES key directly, bypassing all token logic.
  • Reconstructs and decrypts the full video.

The Fix: Token Every Sub-Resource

Every .ts segment URL returned inside the .m3u8 manifest must itself be tokenized before the manifest is served.

  • Your backend generates individual tokens for each .ts segment URL listed in the manifest.
  • The manifest served to the player contains pre-tokenized segment URLs.
  • Your AES key endpoint is also protected by the same secure link logic.

Example of a correctly secured manifest (generated server-side):

text
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-KEY:METHOD=AES-128,URI="https://video.example.com/keys/movie.key?md5=TOKEN_A&expires=1718000000",IV=0x00000001
#EXTINF:6.000,
https://video.example.com/streams/movie/seg001.ts?md5=TOKEN_B&expires=1718000000
#EXTINF:6.000,
https://video.example.com/streams/movie/seg002.ts?md5=TOKEN_C&expires=1718000000
#EXTINF:6.000,
https://video.example.com/streams/movie/seg003.ts?md5=TOKEN_D&expires=1718000000
#EXT-X-ENDLIST

PHP manifest rewriter (simplified):

php
<?php
function tokenizeManifest(
    string $manifestPath,
    string $clientIp,
    string $secret,
    string $baseUrl,
    int $ttl = 3600
): string {
    $lines   = file($manifestPath, FILE_IGNORE_NEW_LINES);
    $expires = time() + $ttl;
    $output  = [];

    foreach ($lines as $line) {
        if (str_ends_with(trim($line), '.ts')) {
            // Tokenize each segment URI
            $uri     = parse_url($line, PHP_URL_PATH) ?? $line;
            $input   = "{$expires}{$uri}{$clientIp} {$secret}";
            $token   = rtrim(strtr(base64_encode(md5($input, true)), '+/', '-_'), '=');
            $output[] = "{$baseUrl}{$uri}?md5={$token}&expires={$expires}";

        } elseif (str_contains($line, 'URI=')) {
            // Tokenize the AES key URI
            preg_match('/URI="([^"]+)"/', $line, $match);
            if (!empty($match[1])) {
                $keyUri  = parse_url($match[1], PHP_URL_PATH);
                $input   = "{$expires}{$keyUri}{$clientIp} {$secret}";
                $token   = rtrim(strtr(base64_encode(md5($input, true)), '+/', '-_'), '=');
                $newUri  = "{$baseUrl}{$keyUri}?md5={$token}&expires={$expires}";
                $line    = preg_replace('/URI="[^"]+"/', "URI=\"{$newUri}\"", $line);
            }
            $output[] = $line;
        } else {
            $output[] = $line;
        }
    }

    return implode("\n", $output);
}

// Serve the tokenized manifest dynamically
header('Content-Type: application/vnd.apple.mpegurl');
echo tokenizeManifest(
    '/var/www/hls/streams/movie/index.m3u8',
    $_SERVER['REMOTE_ADDR'],
    'YourSuperSecretWord2026',
    'https://video.example.com'
);

The Nginx secure_link block in your server config already covers .key endpoints. What this step ensures is that the URLs inside the manifest are correctly pre-tokenized before the manifest reaches the player.

Step 4: Testing and Troubleshooting Common Errors

Quick Functional Test

Valid request (should return 200):

bash
# Generate a test token (adjust values to match your config)
SECRET="YourSuperSecretWord2026"
URI="/streams/movie/index.m3u8"
IP="127.0.0.1"
EXPIRES=$(( $(date +%s) + 3600 ))

TOKEN=$(echo -n "${EXPIRES}${URI}${IP} ${SECRET}" | \
  openssl dgst -md5 -binary | \
  openssl enc -base64 | \
  tr '+/' '-_' | \
  tr -d '=')

curl -I "https://video.example.com${URI}?md5=${TOKEN}&expires=${EXPIRES}"

Expected: HTTP/2 200

Expired request (should return 410):

bash
EXPIRES=1000000000   # A timestamp in the past
# Re-generate token with old timestamp, then curl

Expected: HTTP/2 410

Troubleshooting Table

HTTP Status Nginx $secure_link Value Root Cause Fix
403 Forbidden "" (empty string) Hash mismatch wrong secret, IP mismatch (proxy/CDN), or URI mismatch (trailing slash, encoding difference) Verify secret matches exactly. Log $remote_addr in Nginx to confirm the IP your backend is signing vs. what Nginx sees. Check URI encoding.
410 Gone "0" Token expiration timestamp is in the past Increase $ttl or verify server clocks are NTP-synced (timedatectl status).
404 Not Found "1" (token valid) File doesn't exist at root path, or location regex doesn't match Verify root directory, check file permissions (ls -la), and test your Nginx regex with nginx -t.
403 Forbidden (CDN) N/A CDN or reverse proxy is replacing $remote_addr with its own IP Use $http_x_forwarded_for in the hash formula only if your infrastructure controls the proxy. Otherwise disable IP binding.
400 Bad Request N/A md5 or expires query params are malformed or missing Inspect the generated URL before sending to the player. Log $arg_md5 and $arg_expires in Nginx.

Useful Nginx Debug Logging

Add this temporarily to your server block to inspect token values:

nginx
log_format secure_debug '$remote_addr - [$time_local] '
                         '"$request" $status '
                         'sl=$secure_link '
                         'md5=$arg_md5 '
                         'exp=$arg_expires';

access_log /var/log/nginx/secure_link_debug.log secure_debug;

Remove or change back to your standard log format after debugging.

Conclusion and Next Steps

You now have a complete, production-grade tokenized HLS streaming pipeline:

  • Nginx Secure Link validates every request at the C-level edge, before touching disk.
  • Every .m3u8, .ts, and .key resource requires a valid, time-limited, IP-bound token.
  • The backend generates tokens in PHP or Python using the exact MD5 formula Nginx expects.
  • Expired tokens return 410 Gone; invalid tokens return 403 Forbidden both are cacheable, unlike 401.

What to tackle next: Your video origin is now hardened. The logical next step is scaling delivery globally without rebuilding your security model. You can do this by fronting your Nginx origin with a caching layer or custom CDN using edge nodes your tokenized URLs remain fully compatible since the validation logic lives at the origin. A natural follow-on is building a custom CDN with Nginx and dedicated servers to bring latency down for geographically distributed audiences.

Discover BytesRack Dedicated Server Locations

BytesRack servers are available around the world, providing diverse options for hosting websites. Each region offers unique advantages, making it easier to choose a location that best suits your specific hosting needs.