DEV Community

Cover image for How to Implement Tokenized HLS Streaming Using the Nginx Secure Link Module
Felicia Grace for BytesRack

Posted on • Originally published at bytesrack.com

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

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 at the edge—no middleware, no token server, no extra latency.

It 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.

Key Takeaways

  • Zero application-layer overhead: Nginx validates tokens natively at the edge.
  • Non-transferable tokens: Security relies on a server-side secret combined with the client IP + expiry timestamp + URI.
  • The Trap: Securing only the .m3u8 manifest is not enough. .ts segments and AES key endpoints must be individually protected.
  • Performance: MD5 hashing at the edge is significantly faster than parsing JSON Web Tokens (JWT) for high-throughput video delivery.

Section 1: The Architecture (How Tokenized HLS Works)

Before writing a single config line, understand the full security handshake:

  1. Client requests access: The video player calls your application (PHP, Python, Node.js) to request a playback URL.
  2. Backend generates a time-limited MD5 token: Your application assembles a hash string using a server-side secret, client IP, Unix expiration timestamp, and URI path. It returns a complete tokenized URL to the player.
  3. Player requests the tokenized URL: The player sends a request to Nginx that includes md5 and expires as query parameters.
  4. Nginx intercepts and decides: Nginx recomputes the expected MD5 hash. If they match and haven't expired, it serves the file (200 OK). If they diverge, it returns 403 Forbidden. If expired, 410 Gone.

Why This Beats JWTs for Video Streaming

JSON Web Tokens are excellent for API authentication, but 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 + 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. At 1,000 concurrent viewers, that is 500 validation requests per second. MD5 at the edge absorbs this effortlessly.


Section 2: Prerequisites

  • A dedicated server or VPS with root SSH access.
  • Nginx compiled with --with-http_secure_link_module.
  • HLS files (.m3u8 and .ts) generated and placed in a web-accessible directory.
  • PHP 8+ or Python 3.10+ for backend token generation.

Section 3: Verify and Configure Nginx

First, check that the module is compiled in:

nginx -V 2>&1 | grep -o with-http_secure_link_module
Enter fullscreen mode Exit fullscreen mode

If it returns with-http_secure_link_module, you are good to go.

The Nginx Server Block

Create or edit your server block to cover the HLS manifest and the segment directory:

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

    ssl_certificate     /etc/letsencrypt/live/[video.example.com/fullchain.pem](https://video.example.com/fullchain.pem);
    ssl_certificate_key /etc/letsencrypt/live/[video.example.com/privkey.pem](https://video.example.com/privkey.pem);

    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;
    }
}

Enter fullscreen mode Exit fullscreen mode

Always test the config before reloading: sudo nginx -t && sudo systemctl reload nginx.

Section 4: Generating the Secure Token (Backend Code)

The MD5 input string is assembled in this exact order (spacing matters):
md5( EXPIRES_TIMESTAMP + URI_PATH + CLIENT_IP + " " + SECRET_WORD )

PHP Token Generator

<?php
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](https://video.example.com){$uri}";
    return "{$baseUrl}?md5={$md5}&expires={$expires}";
}

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

echo generateSecureLink($uri, $clientIp, $secret);
?>
Enter fullscreen mode Exit fullscreen mode

Python Token Generator

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](https://video.example.com)") -> str:
    expires = int(time.time()) + ttl
    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}"

if __name__ == "__main__":
    url = generate_secure_link("/streams/movie/index.m3u8", "203.0.113.42", "YourSuperSecretWord2026")
    print(url)
Enter fullscreen mode Exit fullscreen mode

Section 5: The Big Vulnerability (Securing Segments)

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

If you only token-gate the .m3u8 manifest, an attacker only needs to download the manifest once, parse the segment URLs, and fetch every .ts file and AES key directly.

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

Here is a simplified PHP manifest rewriter to handle this dynamically:

<?php
function tokenizeManifest($manifestPath, $clientIp, $secret, $baseUrl, $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')) {
            $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=')) {
            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);
}

header('Content-Type: application/vnd.apple.mpegurl');
echo tokenizeManifest('/var/www/hls/streams/movie/index.m3u8', $_SERVER['REMOTE_ADDR'], 'YourSuperSecretWord2026', '[https://video.example.com](https://video.example.com)');
?>
Enter fullscreen mode Exit fullscreen mode

Section 6: Testing and Troubleshooting

If your tokens are failing, check Nginx's $secure_link variable:

HTTP Status Nginx $secure_link Root Cause Fix
403 Forbidden "" (empty string) Hash mismatch Verify secret matches exactly. Confirm the IP your backend is signing is the same IP Nginx sees.
410 Gone "0" Token expired Increase $ttl or verify server clocks are NTP-synced.
404 Not Found "1" (token valid) File doesn't exist Verify root directory and check file permissions.
403 Forbidden N/A CDN/Proxy IP mismatch Use $http_x_forwarded_for in the hash formula if behind a proxy you control.

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


🚀 Want more infrastructure deep dives?

If you're building high-performance streaming platforms, your software architecture is only as good as your underlying hardware. At Bytesrack, we provide dedicated media origin servers purpose-built to handle massive HLS workloads, high I/O throughput, and heavy bandwidth demands—so you can focus on writing code, not fighting server bottlenecks.

👉 Explore Bytesrack Dedicated Servers
👉 Read more advanced infrastructure tutorials

Got questions about the Nginx config, the backend scripts, or edge security in general? Drop a comment below and let's troubleshoot together!

Top comments (0)