Build a Video Platform: Secure Video Streaming

Your HLS files are on disk, neatly organized in directories. But right now, anyone who guesses the path can download every segment directly. There's no authentication, no expiration, no protection at all. That's a problem if your content is behind a paywall.
In this post, we'll set up Nginx to serve HLS streams with secure_link — a module that validates a hash + expiration on every request. Spring Boot generates signed URLs, and only subscribers with valid tokens can watch. No valid token? Nginx returns a 403 before the segment ever leaves the server.
Time commitment: 2–3 hours
Prerequisites: Phase 6: Video Transcoding Pipeline
What we'll build in this post:
✅ Nginx configuration for serving HLS segments
✅ secure_link module for token-based URL validation
✅ Signed URL generation in Spring Boot
✅ CORS headers for cross-origin video playback
✅ Download prevention strategies
✅ Playlist URL rewriting for signed streaming
✅ Complete request flow from auth check to video playback
The Problem: Unprotected Video Files
Without protection, your videos are just static files on a web server:
https://yoursite.com/videos/hls/1/1/master.m3u8 → Anyone can access
https://yoursite.com/videos/hls/1/1/720p/segment-000.ts → Anyone can downloadSomeone could:
- Share the direct URL and bypass your subscription
- Write a script to download all segments and reconstruct the video
- Hotlink your videos from their own site, using your bandwidth
We need a way to validate that every request for a video segment comes from an authorized user, and that the URL hasn't been shared or expired.
Architecture Overview
Here's how secure streaming works:
The key insight: Nginx validates every request — not just the playlist, but every single .ts segment. The token is tied to a base path (like /videos/hls/1/1/), so it covers the master playlist and all quality levels and segments under that path.
Nginx Configuration
Basic HLS Serving
First, let's set up Nginx to serve HLS files from the video storage directory:
# nginx/conf.d/hls.conf
# Rate limiting zone — prevent abuse
limit_req_zone $binary_remote_addr zone=hls_limit:10m rate=30r/s;
server {
listen 80;
server_name localhost;
# HLS video streaming with secure_link
location /videos/hls/ {
# Secure link validation
secure_link $arg_token,$arg_expires;
secure_link_md5 "$secure_link_expires$uri$remote_addr YOUR_SECRET_KEY";
# If secure_link is empty — invalid hash
if ($secure_link = "") {
return 403;
}
# If secure_link is "0" — link expired
if ($secure_link = "0") {
return 410; # Gone
}
# Serve from video storage directory
alias /data/videos/hls/;
# Rate limiting
limit_req zone=hls_limit burst=50 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range, Authorization" always;
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
# Cache control for segments
location ~* \.m3u8$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
types {
application/vnd.apple.mpegurl m3u8;
}
}
location ~* \.ts$ {
add_header Cache-Control "public, max-age=86400";
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
types {
video/mp2t ts;
}
}
# Prevent downloading — no Content-Disposition header
add_header Content-Disposition "";
# Disable directory listing
autoindex off;
}
# Handle CORS preflight requests
location /videos/hls/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "Range, Authorization";
add_header Access-Control-Max-Age 86400;
add_header Content-Length 0;
return 204;
}
}
# Block direct access to raw uploads
location /videos/raw/ {
deny all;
return 403;
}
}How secure_link Works
The secure_link module is built into Nginx (you may need to compile with --with-http_secure_link_module, but most distributions include it). Here's the flow:
The secure_link_md5 directive defines how the hash is computed:
secure_link_md5 "$secure_link_expires$uri$remote_addr YOUR_SECRET_KEY";This means: MD5(expiration_timestamp + request_URI + client_IP + secret_key). The client must provide the exact same hash in the token query parameter. Since only your Spring Boot server knows the secret key, only it can generate valid tokens.
Important: $remote_addr ties the token to the client's IP address. This prevents token sharing — a URL signed for one user won't work from another IP. If your users are behind a corporate proxy or VPN where IPs change, you can remove $remote_addr from the hash and rely on expiration alone.
Signed URL Generation in Spring Boot
Configuration
// src/main/java/com/videoplatform/api/config/StreamingConfig.java
package com.videoplatform.api.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "streaming")
public class StreamingConfig {
private String secretKey = "change-me-in-production";
private int urlExpirationSeconds = 7200; // 2 hours
private String baseUrl = "http://localhost/videos/hls";
// Getters and setters
public String getSecretKey() { return secretKey; }
public void setSecretKey(String secretKey) { this.secretKey = secretKey; }
public int getUrlExpirationSeconds() { return urlExpirationSeconds; }
public void setUrlExpirationSeconds(int urlExpirationSeconds) {
this.urlExpirationSeconds = urlExpirationSeconds;
}
public String getBaseUrl() { return baseUrl; }
public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
}# src/main/resources/application.yml
streaming:
secret-key: ${STREAMING_SECRET_KEY:change-me-in-production}
url-expiration-seconds: 7200
base-url: ${STREAMING_BASE_URL:http://localhost/videos/hls}URL Signing Service
// src/main/java/com/videoplatform/api/service/SignedUrlService.java
package com.videoplatform.api.service;
import com.videoplatform.api.config.StreamingConfig;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
@Service
public class SignedUrlService {
private final StreamingConfig config;
public SignedUrlService(StreamingConfig config) {
this.config = config;
}
/**
* Generate a signed URL for HLS streaming.
*
* @param courseId Course ID
* @param lessonId Lesson ID
* @param clientIp Client IP address (for IP binding)
* @return Signed URL for the master playlist
*/
public String generateSignedPlaylistUrl(Long courseId, Long lessonId, String clientIp) {
String uri = String.format("/videos/hls/%d/%d/master.m3u8", courseId, lessonId);
long expires = Instant.now().getEpochSecond() + config.getUrlExpirationSeconds();
String token = computeToken(uri, expires, clientIp);
return String.format("%s/%d/%d/master.m3u8?token=%s&expires=%d",
config.getBaseUrl(), courseId, lessonId, token, expires);
}
/**
* Generate a signed base URL for segment requests.
* The player will append segment paths to this base.
*/
public SignedStreamInfo generateSignedStreamInfo(Long courseId, Long lessonId, String clientIp) {
long expires = Instant.now().getEpochSecond() + config.getUrlExpirationSeconds();
// Sign the master playlist
String masterUri = String.format("/videos/hls/%d/%d/master.m3u8", courseId, lessonId);
String masterToken = computeToken(masterUri, expires, clientIp);
String masterUrl = String.format("%s/%d/%d/master.m3u8?token=%s&expires=%d",
config.getBaseUrl(), courseId, lessonId, masterToken, expires);
return new SignedStreamInfo(masterUrl, expires);
}
private String computeToken(String uri, long expires, String clientIp) {
try {
// Must match Nginx secure_link_md5 directive:
// secure_link_md5 "$secure_link_expires$uri$remote_addr YOUR_SECRET_KEY";
String raw = expires + uri + clientIp + config.getSecretKey();
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hash = md.digest(raw.getBytes(StandardCharsets.UTF_8));
// Nginx expects Base64url encoding (no padding)
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(hash);
} catch (Exception e) {
throw new RuntimeException("Failed to compute secure link token", e);
}
}
public record SignedStreamInfo(
String masterPlaylistUrl,
long expiresAt
) {}
}Why MD5?
You might notice we're using MD5, which is cryptographically broken for collision resistance. That's fine here — secure_link uses MD5 as an HMAC-like construction (secret key + message). We're not using it for password hashing or digital signatures. The security comes from the secret key being unknown to the client, not from the hash algorithm's collision resistance. This is Nginx's design, and it works well for URL signing.
Streaming API Endpoint
Stream Controller
// src/main/java/com/videoplatform/api/controller/StreamController.java
package com.videoplatform.api.controller;
import com.videoplatform.api.dto.response.ApiResponse;
import com.videoplatform.api.dto.response.StreamResponse;
import com.videoplatform.api.entity.Lesson;
import com.videoplatform.api.entity.LessonStatus;
import com.videoplatform.api.exception.AccessDeniedException;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.repository.LessonRepository;
import com.videoplatform.api.service.SignedUrlService;
import com.videoplatform.api.service.SubscriptionService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/lessons")
public class StreamController {
private final LessonRepository lessonRepository;
private final SignedUrlService signedUrlService;
private final SubscriptionService subscriptionService;
public StreamController(
LessonRepository lessonRepository,
SignedUrlService signedUrlService,
SubscriptionService subscriptionService) {
this.lessonRepository = lessonRepository;
this.signedUrlService = signedUrlService;
this.subscriptionService = subscriptionService;
}
@GetMapping("/{lessonId}/stream")
public ResponseEntity<ApiResponse<StreamResponse>> getStreamUrl(
@PathVariable Long lessonId,
@AuthenticationPrincipal UserDetails userDetails,
HttpServletRequest request) {
// 1. Find the lesson
Lesson lesson = lessonRepository.findById(lessonId)
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found: " + lessonId));
// 2. Check if video is ready
if (lesson.getStatus() != LessonStatus.READY) {
throw new ResourceNotFoundException("Video not available for this lesson");
}
// 3. Allow free preview lessons without subscription
if (!lesson.isFreePreview()) {
// 4. Check subscription
boolean hasAccess = subscriptionService.hasActiveSubscription(userDetails.getUsername());
if (!hasAccess) {
throw new AccessDeniedException("Active subscription required to watch this lesson");
}
}
// 5. Generate signed URL
String clientIp = getClientIp(request);
Long courseId = lesson.getSection().getCourse().getId();
var streamInfo = signedUrlService.generateSignedStreamInfo(courseId, lessonId, clientIp);
StreamResponse response = new StreamResponse(
streamInfo.masterPlaylistUrl(),
streamInfo.expiresAt(),
lesson.getDuration()
);
return ResponseEntity.ok(ApiResponse.success(response));
}
private String getClientIp(HttpServletRequest request) {
// Check X-Forwarded-For header (set by reverse proxy)
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isEmpty()) {
// Take the first IP (client's real IP)
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}Response DTO
// src/main/java/com/videoplatform/api/dto/response/StreamResponse.java
package com.videoplatform.api.dto.response;
public record StreamResponse(
String playlistUrl,
long expiresAt,
int durationSeconds
) {}Subscription Check
// src/main/java/com/videoplatform/api/service/SubscriptionService.java
package com.videoplatform.api.service;
import com.videoplatform.api.entity.Subscription;
import com.videoplatform.api.entity.SubscriptionStatus;
import com.videoplatform.api.repository.SubscriptionRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Service
public class SubscriptionService {
private final SubscriptionRepository subscriptionRepository;
public SubscriptionService(SubscriptionRepository subscriptionRepository) {
this.subscriptionRepository = subscriptionRepository;
}
public boolean hasActiveSubscription(String email) {
return subscriptionRepository
.findByUserEmailAndStatus(email, SubscriptionStatus.ACTIVE)
.filter(sub -> sub.getCurrentPeriodEnd().isAfter(LocalDateTime.now()))
.isPresent();
}
}// src/main/java/com/videoplatform/api/repository/SubscriptionRepository.java
package com.videoplatform.api.repository;
import com.videoplatform.api.entity.Subscription;
import com.videoplatform.api.entity.SubscriptionStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {
Optional<Subscription> findByUserEmailAndStatus(String email, SubscriptionStatus status);
}Handling Segment URLs
There's a subtle problem. When hls.js fetches the master playlist, it gets back relative segment paths:
#EXT-X-STREAM-INF:BANDWIDTH=2628000,RESOLUTION=1280x720,NAME="720p"
720p/playlist.m3u8The player resolves this relative to the master playlist URL. If the master URL is:
https://yoursite.com/videos/hls/1/1/master.m3u8?token=abc&expires=123Then the player will request:
https://yoursite.com/videos/hls/1/1/720p/playlist.m3u8Notice the problem? The token and expires parameters are stripped because they were only on the master URL. The segment request will get a 403 from Nginx.
Solution: Token-Aware Nginx Configuration
We modify the Nginx config to use a path-based token that covers the entire course/lesson directory:
# nginx/conf.d/hls.conf — updated
server {
listen 80;
server_name localhost;
# Extract course and lesson IDs from the path
# Pattern: /videos/hls/{courseId}/{lessonId}/...
location ~ ^/videos/hls/(\d+)/(\d+)/ {
set $course_id $1;
set $lesson_id $2;
# Secure link validation — hash covers the base path, not the full URI
secure_link $arg_token,$arg_expires;
secure_link_md5 "$secure_link_expires/videos/hls/$course_id/$lesson_id/$remote_addr YOUR_SECRET_KEY";
# Invalid hash
if ($secure_link = "") {
return 403;
}
# Expired link
if ($secure_link = "0") {
return 410;
}
# Serve from video storage
alias /data/videos/hls/$course_id/$lesson_id/;
# Try the specific file
try_files $uri =404;
# Rate limiting
limit_req zone=hls_limit burst=50 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range" always;
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
# MIME types
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
# Cache control
if ($uri ~* \.m3u8$) {
add_header Cache-Control "no-cache";
}
if ($uri ~* \.ts$) {
add_header Cache-Control "public, max-age=86400";
}
}
# CORS preflight
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "Range";
add_header Access-Control-Max-Age 86400;
return 204;
}
# Block raw video access
location /videos/raw/ {
deny all;
}
}The key change is in secure_link_md5: instead of using $uri (which changes for every segment), we use the fixed base path /videos/hls/$course_id/$lesson_id/. This means one token works for all files under that lesson directory.
Update the Signing Service
// Update SignedUrlService.java — computeToken now uses base path
private String computeToken(String basePath, long expires, String clientIp) {
try {
// Must match Nginx secure_link_md5 directive:
// secure_link_md5 "$secure_link_expires/videos/hls/$course_id/$lesson_id/$remote_addr SECRET";
String raw = expires + basePath + clientIp + config.getSecretKey();
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hash = md.digest(raw.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(hash);
} catch (Exception e) {
throw new RuntimeException("Failed to compute secure link token", e);
}
}
public SignedStreamInfo generateSignedStreamInfo(Long courseId, Long lessonId, String clientIp) {
long expires = Instant.now().getEpochSecond() + config.getUrlExpirationSeconds();
// Sign against the base path (not the full URI)
String basePath = String.format("/videos/hls/%d/%d/", courseId, lessonId);
String token = computeToken(basePath, expires, clientIp);
// Token will be appended to all requests under this path
String masterUrl = String.format("%s/%d/%d/master.m3u8?token=%s&expires=%d",
config.getBaseUrl(), courseId, lessonId, token, expires);
return new SignedStreamInfo(masterUrl, token, expires);
}
public record SignedStreamInfo(
String masterPlaylistUrl,
String token,
long expiresAt
) {}hls.js Token Injection
On the frontend, configure hls.js to append the token to every request:
// web/src/lib/hls-config.ts
export function createHlsConfig(token: string, expires: number) {
return {
xhrSetup: (xhr: XMLHttpRequest, url: string) => {
// Append token and expires to every HLS request
const separator = url.includes("?") ? "&" : "?";
const signedUrl = `${url}${separator}token=${token}&expires=${expires}`;
xhr.open("GET", signedUrl, true);
},
};
}// Usage in the video player component (we'll build this fully in Post #9)
import Hls from "hls.js";
import { createHlsConfig } from "@/lib/hls-config";
// When initializing hls.js:
const hls = new Hls(createHlsConfig(streamInfo.token, streamInfo.expiresAt));
hls.loadSource(streamInfo.masterPlaylistUrl);
hls.attachMedia(videoElement);This way, every subrequest (quality playlists, segments) automatically includes the token. Nginx validates the same token for all requests under that lesson's path.
CORS Configuration
Cross-Origin Resource Sharing (CORS) is critical because the video player on your Next.js domain (app.yoursite.com) needs to fetch HLS segments from Nginx (cdn.yoursite.com or the same domain on a different port).
Why CORS Matters for Video
Without proper CORS headers:
- hls.js can't fetch the master playlist (blocked by browser)
- Quality switching fails (can't load alternative playlists)
Rangerequests for seeking don't work
Nginx CORS Headers Explained
# Allow your frontend origin
add_header Access-Control-Allow-Origin $http_origin always;
# Only GET and OPTIONS needed for HLS
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
# Range header is essential for video seeking
add_header Access-Control-Allow-Headers "Range" always;
# Expose these so hls.js can read content length for progress
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;Production tip: Replace $http_origin with your exact domain for tighter security:
# Instead of echoing back any origin:
add_header Access-Control-Allow-Origin $http_origin always;
# Use your specific domain:
add_header Access-Control-Allow-Origin "https://app.yoursite.com" always;Spring Boot CORS for the Stream API
// src/main/java/com/videoplatform/api/config/CorsConfig.java
package com.videoplatform.api.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class CorsConfig {
@Value("${app.frontend-url:http://localhost:3000}")
private String frontendUrl;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(frontendUrl));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}Preventing Direct Downloads
Even with signed URLs, a determined user could use developer tools to copy segment URLs and download them with curl. Here are layers of defense:
1. Short Expiration Times
Set URL expiration to 2 hours (enough for a long viewing session, but not for sharing):
streaming:
url-expiration-seconds: 7200 # 2 hours2. IP Binding
The $remote_addr in the hash ties the token to the viewer's IP. A URL copied to a different machine won't work:
secure_link_md5 "$secure_link_expires/videos/hls/$course_id/$lesson_id/$remote_addr YOUR_SECRET_KEY";3. Rate Limiting
Prevent automated bulk downloading:
# 30 requests per second per IP — enough for normal playback, blocks scrapers
limit_req_zone $binary_remote_addr zone=hls_limit:10m rate=30r/s;
limit_req zone=hls_limit burst=50 nodelay;A 30-minute video has about 180 segments (at 10s each) across two qualities — roughly 360 segments total. Normal playback fetches these over 30 minutes. A scraper trying to download all segments at once will hit the rate limit.
4. Response Headers
# Remove Content-Disposition to prevent "Save As" dialog
add_header Content-Disposition "" always;
# Prevent MIME sniffing
add_header X-Content-Type-Options "nosniff" always;
# Prevent embedding in iframes on other sites
add_header X-Frame-Options "SAMEORIGIN" always;5. Referrer Checking (Optional)
Block requests that don't come from your site:
valid_referers server_names *.yoursite.com;
if ($invalid_referer) {
return 403;
}Defense in Depth Diagram
Reality check: No DRM-free streaming solution is 100% piracy-proof. A determined user can always screen-record. These measures prevent casual sharing and automated scraping — which covers 99% of cases. True DRM (Widevine/FairPlay) is covered in the security hardening post.
Docker Compose Configuration
Update your Docker Compose to include Nginx for HLS serving:
# docker-compose.yml — add nginx service
services:
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- video-storage:/data/videos:ro # Read-only access to videos
depends_on:
- api
- web
restart: unless-stopped
api:
build: ./api
ports:
- "8080:8080"
environment:
STREAMING_SECRET_KEY: ${STREAMING_SECRET_KEY}
STREAMING_BASE_URL: http://localhost/videos/hls
volumes:
- video-storage:/data/videos
depends_on:
- postgres
- redis
web:
build: ./web
ports:
- "3000:3000"
environment:
NEXT_PUBLIC_API_URL: http://localhost:8080
NEXT_PUBLIC_STREAM_BASE_URL: http://localhost/videos/hls
depends_on:
- api
# ... postgres and redis services unchanged
volumes:
video-storage:Full Nginx Configuration with Reverse Proxy
In production, Nginx serves as both the reverse proxy for your app and the HLS server:
# nginx/conf.d/default.conf
upstream api {
server api:8080;
}
upstream web {
server web:3000;
}
limit_req_zone $binary_remote_addr zone=hls_limit:10m rate=30r/s;
server {
listen 80;
server_name yoursite.com;
# API proxy
location /api/ {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Important for multipart uploads
client_max_body_size 2G;
}
# HLS streaming with secure_link
location ~ ^/videos/hls/(\d+)/(\d+)/ {
set $course_id $1;
set $lesson_id $2;
secure_link $arg_token,$arg_expires;
secure_link_md5 "$secure_link_expires/videos/hls/$course_id/$lesson_id/$remote_addr YOUR_SECRET_KEY";
if ($secure_link = "") {
return 403;
}
if ($secure_link = "0") {
return 410;
}
alias /data/videos/hls/$course_id/$lesson_id/;
try_files $uri =404;
limit_req zone=hls_limit burst=50 nodelay;
add_header Access-Control-Allow-Origin "https://yoursite.com" always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range" always;
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
}
# Block raw video access
location /videos/raw/ {
deny all;
}
# Next.js frontend (catch-all)
location / {
proxy_pass http://web;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Environment Variables
Add the streaming secret to your .env:
# .env
STREAMING_SECRET_KEY=your-random-64-char-secret-key-here
STREAMING_BASE_URL=http://localhost/videos/hls
# Generate a random key:
# openssl rand -hex 32Critical: The YOUR_SECRET_KEY in nginx.conf and STREAMING_SECRET_KEY in Spring Boot must match exactly. If they don't, every signed URL will fail validation.
Testing the Complete Flow
1. Test URL Signing
# Get a signed stream URL (requires auth token)
curl -s http://localhost:8080/api/lessons/1/stream \
-H "Authorization: Bearer ${TOKEN}" | jq
# Response:
{
"data": {
"playlistUrl": "http://localhost/videos/hls/1/1/master.m3u8?token=abc123&expires=1711234567",
"expiresAt": 1711234567,
"durationSeconds": 1800
}
}2. Test Direct Access (Should Fail)
# Without token — should get 403
curl -v http://localhost/videos/hls/1/1/master.m3u8
# < HTTP/1.1 403 Forbidden
# With invalid token — should get 403
curl -v "http://localhost/videos/hls/1/1/master.m3u8?token=invalid&expires=9999999999"
# < HTTP/1.1 403 Forbidden3. Test Signed Access (Should Work)
# Use the URL from step 1
curl -v "http://localhost/videos/hls/1/1/master.m3u8?token=abc123&expires=1711234567"
# < HTTP/1.1 200 OK
# < Content-Type: application/vnd.apple.mpegurl
# #EXTM3U
# #EXT-X-VERSION:3
# ...4. Test Segment Access with Same Token
# The same token should work for segments under the same lesson
curl -v "http://localhost/videos/hls/1/1/720p/playlist.m3u8?token=abc123&expires=1711234567"
# < HTTP/1.1 200 OK
curl -v "http://localhost/videos/hls/1/1/720p/segment-000.ts?token=abc123&expires=1711234567"
# < HTTP/1.1 200 OK5. Test Expired URL
# Set expires to a past timestamp
curl -v "http://localhost/videos/hls/1/1/master.m3u8?token=abc123&expires=1000000000"
# < HTTP/1.1 410 Gone6. Test Playback with ffplay
# Quick playback test using the signed URL
ffplay "http://localhost/videos/hls/1/1/master.m3u8?token=abc123&expires=1711234567"Nginx Debug Tips
When secure_link returns 403 and you're not sure why, use Nginx debug logging:
# In the server block, temporarily add:
error_log /var/log/nginx/error.log debug;Then check the logs:
docker compose exec nginx tail -f /var/log/nginx/error.logCommon issues:
| Symptom | Cause | Fix |
|---|---|---|
| Always 403 | Secret key mismatch | Ensure Nginx and Spring Boot use the same key |
| Always 403 | Hash encoding mismatch | Nginx expects Base64url without padding |
| Works locally, fails behind proxy | IP mismatch | Ensure X-Forwarded-For is set and read correctly |
| First request works, segments fail | Token tied to full URI | Use base path in secure_link_md5 (our approach) |
| Expired too quickly | Clock skew | Sync server clocks with NTP |
Verify the Hash Manually
You can compute the expected hash yourself to debug:
# Variables — must match what Nginx expects
EXPIRES="1711234567"
BASE_PATH="/videos/hls/1/1/"
CLIENT_IP="172.18.0.1"
SECRET="your-secret-key"
# Compute MD5 (same as Nginx secure_link_md5)
echo -n "${EXPIRES}${BASE_PATH}${CLIENT_IP}${SECRET}" | \
openssl md5 -binary | \
openssl base64 | \
tr '+/' '-_' | \
tr -d '='
# Output should match the token parameter in the signed URLCommon Mistakes
1. Secret Key Mismatch Between Nginx and Spring Boot
This is the #1 issue. The key in nginx.conf and application.yml must be identical:
# nginx.conf
secure_link_md5 "...$remote_addr MY_SECRET_KEY";# application.yml
streaming:
secret-key: MY_SECRET_KEY # Must match exactly!Use an environment variable in both places to avoid drift.
2. Forgetting to Forward Client IP
Behind a reverse proxy, $remote_addr is the proxy's IP, not the client's. Spring Boot reads the real IP from X-Forwarded-For, but if Nginx's upstream proxy doesn't set this header, the IPs won't match:
# In the API proxy location
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;3. CORS Missing on Error Responses
Nginx's add_header doesn't apply to error responses by default. Use the always parameter:
# WRONG — CORS headers not sent on 403/410
add_header Access-Control-Allow-Origin $http_origin;
# RIGHT — CORS headers sent on all responses including errors
add_header Access-Control-Allow-Origin $http_origin always;Without always, the browser can't even read the 403 status — it just sees a CORS error, making debugging impossible.
4. Caching Playlists
Never cache .m3u8 playlists aggressively. If the player gets a stale playlist, it might request segments that don't exist anymore (e.g., after re-transcoding):
# Playlists: never cache
location ~* \.m3u8$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Segments: cache aggressively (they never change)
location ~* \.ts$ {
add_header Cache-Control "public, max-age=86400";
}What's Next?
We have secure streaming working — signed URLs, token validation, CORS, and download prevention. But we still need a proper video player. In Post #9, we'll build a custom React video player:
- hls.js integration with quality switching
- Custom player controls (play/pause, seek, volume, fullscreen)
- Playback speed selection
- Resume from last position (progress saved to database)
- Debounced progress saves with
sendBeaconon page unload
Time to give users a beautiful way to watch those secured streams.
Series: Build a Video Streaming Platform
Previous: Phase 6: Video Transcoding Pipeline
Next: Phase 8: Video Player & Progress Tracking
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.