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
.m3u8manifest is not enough..tssegments 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:
- Client requests access: The video player calls your application (PHP, Python, Node.js) to request a playback URL.
- 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.
- Player requests the tokenized URL: The player sends a request to Nginx that includes
md5andexpiresas query parameters. - 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 returns403 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 (
.m3u8and.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
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;
}
}
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);
?>
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)
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)');
?>
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)